From 2278356bd81b06fb0f6408241bb6064752948802 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 05:45:57 +0000 Subject: [PATCH 01/12] CAMEL-23861: Add GenAI observability for langchain4j and OpenAI (Phase 1) Introduce camel-ai-observability module with OpenTelemetry GenAI span attributes and Micrometer metrics aligned with Spring AI semconv. Integrate per-LLM-call child spans and metrics into langchain4j-chat, langchain4j-tools, langchain4j-agent, langchain4j-embeddings, and openai producers. Add request/response model headers on langchain4j components. Global toggle via camel.ai.observability.enabled. Co-authored-by: Cursor --- catalog/camel-allcomponents/pom.xml | 5 + .../camel-ai/camel-ai-observability/pom.xml | 69 +++++ .../org/apache/camel/other.properties | 7 + .../generated/resources/ai-observability.json | 15 ++ .../src/main/docs/ai-observability.adoc | 28 ++ .../ai/observability/GenAiAttributes.java | 36 +++ .../ai/observability/GenAiMetrics.java | 39 +++ .../ai/observability/GenAiModelResolver.java | 91 +++++++ .../ai/observability/GenAiObservability.java | 243 ++++++++++++++++++ .../GenAiObservabilityProperties.java | 31 +++ .../ai/observability/GenAiObservation.java | 36 +++ .../GenAiObservationContext.java | 91 +++++++ .../ai/observability/GenAiOperationName.java | 36 +++ .../ai/observability/GenAiUsage.java | 32 +++ .../observability/GenAiModelResolverTest.java | 57 ++++ .../observability/GenAiObservabilityTest.java | 96 +++++++ .../GenAiObservationContextTest.java | 43 ++++ .../langchain4j/agent/api/Headers.java | 6 + .../camel-ai/camel-langchain4j-agent/pom.xml | 5 + .../langchain4j/agent/langchain4j-agent.json | 6 +- .../agent/LangChain4jAgentProducer.java | 46 +++- .../camel-ai/camel-langchain4j-chat/pom.xml | 6 + .../chat/LangChain4jChatHeaders.java | 6 + .../chat/LangChain4jChatProducer.java | 74 +++++- .../camel-langchain4j-embeddings/pom.xml | 6 + .../LangChain4jEmbeddingsHeaders.java | 6 + .../LangChain4jEmbeddingsProducer.java | 39 ++- .../camel-ai/camel-langchain4j-tools/pom.xml | 6 + .../tools/LangChain4jToolsHeaders.java | 6 + .../tools/LangChain4jToolsProducer.java | 45 +++- components/camel-ai/camel-openai/pom.xml | 6 + .../component/openai/OpenAIProducer.java | 35 ++- components/camel-ai/pom.xml | 1 + .../pages/camel-4x-upgrade-guide-4_22.adoc | 10 + parent/pom.xml | 5 + .../camel/maven/packaging/MojoHelper.java | 3 +- 36 files changed, 1241 insertions(+), 31 deletions(-) create mode 100644 components/camel-ai/camel-ai-observability/pom.xml create mode 100644 components/camel-ai/camel-ai-observability/src/generated/resources/META-INF/services/org/apache/camel/other.properties create mode 100644 components/camel-ai/camel-ai-observability/src/generated/resources/ai-observability.json create mode 100644 components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetrics.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservationContextTest.java diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index 9ff95f7750ff9..1742b48b76881 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -67,6 +67,11 @@ camel-activemq6 ${project.version} + + org.apache.camel + camel-ai-observability + ${project.version} + org.apache.camel camel-ai-tool diff --git a/components/camel-ai/camel-ai-observability/pom.xml b/components/camel-ai/camel-ai-observability/pom.xml new file mode 100644 index 0000000000000..1cbf3e516cf1e --- /dev/null +++ b/components/camel-ai/camel-ai-observability/pom.xml @@ -0,0 +1,69 @@ + + + + + 4.0.0 + + + camel-ai-parent + org.apache.camel + 4.22.0-SNAPSHOT + + + camel-ai-observability + jar + Camel :: AI :: Observability + GenAI observability support for Camel AI components (OpenTelemetry and Micrometer) + + + 4.22.0 + + AI Observability + Preview + + + + + org.apache.camel + camel-support + + + org.apache.camel + camel-telemetry + + + io.micrometer + micrometer-core + + + + org.apache.camel + camel-test-junit6 + test + + + org.assertj + assertj-core + test + + + + diff --git a/components/camel-ai/camel-ai-observability/src/generated/resources/META-INF/services/org/apache/camel/other.properties b/components/camel-ai/camel-ai-observability/src/generated/resources/META-INF/services/org/apache/camel/other.properties new file mode 100644 index 0000000000000..29afb5bd2882d --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/generated/resources/META-INF/services/org/apache/camel/other.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +name=ai-observability +groupId=org.apache.camel +artifactId=camel-ai-observability +version=4.22.0-SNAPSHOT +projectName=Camel :: AI :: Observability +projectDescription=GenAI observability support for Camel AI components (OpenTelemetry and Micrometer) diff --git a/components/camel-ai/camel-ai-observability/src/generated/resources/ai-observability.json b/components/camel-ai/camel-ai-observability/src/generated/resources/ai-observability.json new file mode 100644 index 0000000000000..81301a783591c --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/generated/resources/ai-observability.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "ai-observability", + "title": "AI Observability", + "description": "GenAI observability support for Camel AI components (OpenTelemetry and Micrometer)", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-ai-observability", + "version": "4.22.0-SNAPSHOT" + } +} 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 new file mode 100644 index 0000000000000..941615e43ff0d --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc @@ -0,0 +1,28 @@ += AI Observability + +The `camel-ai-observability` module provides GenAI observability for Camel AI components following the +OpenTelemetry semantic conventions for GenAI (stable subset aligned with Spring AI). + +When `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath, langchain4j and OpenAI producers +emit child spans and metrics per LLM call with attributes such as `gen_ai.operation.name`, `gen_ai.system`, +`gen_ai.request.model`, `gen_ai.usage.input_tokens`, and `gen_ai.usage.output_tokens`. + +Global toggle: set `camel.ai.observability.enabled=false` to disable GenAI observability across all AI +components (default is `true` when a tracing or metrics backend is present). + +Phase 1 coverage: `langchain4j-chat`, `langchain4j-tools`, `langchain4j-agent`, `langchain4j-embeddings`, +and `openai`. + +New exchange headers for model identification on langchain4j components: + +* `CamelLangChain4jChatRequestModel` / `CamelLangChain4jChatResponseModel` +* `CamelLangChain4jToolsRequestModel` / `CamelLangChain4jToolsResponseModel` +* `CamelLangChain4jAgentRequestModel` / `CamelLangChain4jAgentResponseModel` +* `CamelLangChain4jEmbeddingsRequestModel` / `CamelLangChain4jEmbeddingsResponseModel` + +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`) + +See xref:components:ai-observability.adoc[AI Observability] for details. diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java new file mode 100644 index 0000000000000..2357ce889fcd1 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * OpenTelemetry GenAI semantic convention attribute keys (stable subset aligned with Spring AI). + */ +public final class GenAiAttributes { + + public static final String OPERATION_NAME = "gen_ai.operation.name"; + public static final String SYSTEM = "gen_ai.system"; + public static final String REQUEST_MODEL = "gen_ai.request.model"; + public static final String RESPONSE_MODEL = "gen_ai.response.model"; + public static final String INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String FINISH_REASONS = "gen_ai.response.finish_reasons"; + public static final String ERROR_TYPE = "error.type"; + public static final String CAMEL_COMPONENT = "camel.component"; + + private GenAiAttributes() { + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetrics.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetrics.java new file mode 100644 index 0000000000000..794f06569b14a --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetrics.java @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * Micrometer metric names aligned with Spring AI GenAI observability. + */ +public final class GenAiMetrics { + + public static final String CLIENT_OPERATION = "gen_ai.client.operation"; + public static final String CLIENT_TOKEN_USAGE = "gen_ai.client.token.usage"; + + public static final String TAG_OPERATION_NAME = "gen_ai.operation.name"; + public static final String TAG_SYSTEM = "gen_ai.system"; + public static final String TAG_REQUEST_MODEL = "gen_ai.request.model"; + public static final String TAG_TOKEN_TYPE = "gen_ai.token.type"; + public static final String TAG_ERROR_TYPE = "error.type"; + public static final String TAG_CAMEL_COMPONENT = "camel.component"; + + public static final String TOKEN_TYPE_INPUT = "input"; + public static final String TOKEN_TYPE_OUTPUT = "output"; + + private GenAiMetrics() { + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java new file mode 100644 index 0000000000000..ece643c75fa5a --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java @@ -0,0 +1,91 @@ +/* + * 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.lang.reflect.Method; + +/** + * Resolves GenAI provider and model metadata from LangChain4j model beans. + */ +public final class GenAiModelResolver { + + private static final String UNKNOWN = "unknown"; + + private GenAiModelResolver() { + } + + public static String resolveSystem(Object model) { + if (model == null) { + return UNKNOWN; + } + String className = model.getClass().getName().toLowerCase(); + if (className.contains("openai")) { + return "openai"; + } + if (className.contains("anthropic")) { + return "anthropic"; + } + if (className.contains("ollama")) { + return "ollama"; + } + if (className.contains("azure")) { + return "azure.ai.openai"; + } + if (className.contains("vertex") || className.contains("google")) { + return "gcp.vertex_ai"; + } + if (className.contains("mistral")) { + return "mistral_ai"; + } + if (className.contains("huggingface") || className.contains("hugging")) { + return "huggingface"; + } + if (className.contains("bedrock") || className.contains("amazon")) { + return "aws.bedrock"; + } + return UNKNOWN; + } + + public static String resolveModelName(Object model) { + if (model == null) { + return UNKNOWN; + } + String fromMethod = invokeStringMethod(model, "modelName"); + if (fromMethod != null && !fromMethod.isBlank()) { + return fromMethod; + } + fromMethod = invokeStringMethod(model, "getModelName"); + if (fromMethod != null && !fromMethod.isBlank()) { + return fromMethod; + } + fromMethod = invokeStringMethod(model, "model"); + if (fromMethod != null && !fromMethod.isBlank()) { + return fromMethod; + } + return UNKNOWN; + } + + private static String invokeStringMethod(Object target, String methodName) { + try { + Method method = target.getClass().getMethod(methodName); + Object value = method.invoke(target); + return value != null ? value.toString() : null; + } catch (ReflectiveOperationException e) { + return null; + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java new file mode 100644 index 0000000000000..8fd61f061dd1f --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -0,0 +1,243 @@ +/* + * 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.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.support.CamelContextHelper; +import org.apache.camel.telemetry.Span; +import org.apache.camel.telemetry.SpanLifecycleManager; +import org.apache.camel.telemetry.SpanStorageManagerExchange; +import org.apache.camel.telemetry.Tracer; +import org.apache.camel.util.ObjectHelper; + +/** + * Entry point for GenAI observability in Camel AI producers. + */ +public final class GenAiObservability { + + private static final String INTERNAL_SPAN_KIND = "INTERNAL"; + private static final GenAiObservation NOOP = new NoopGenAiObservation(); + + private GenAiObservability() { + } + + /** + * Whether GenAI observability is enabled for the given context. + */ + public static boolean isEnabled(CamelContext camelContext) { + if (camelContext == null) { + return false; + } + java.util.Optional property + = camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED); + if (property.isPresent()) { + return Boolean.parseBoolean(property.get().trim()); + } + return true; + } + + /** + * Starts a GenAI observation for a single LLM client call. Returns a no-op when disabled or no backend is + * available. + */ + public static GenAiObservation start(Exchange exchange, GenAiObservationContext context) { + if (exchange == null || context == null || !isEnabled(exchange.getContext())) { + return NOOP; + } + Tracer tracer = CamelContextHelper.findSingleByType(exchange.getContext(), Tracer.class); + MeterRegistry meterRegistry = resolveMeterRegistry(exchange.getContext()); + if (tracer == null && meterRegistry == null) { + return NOOP; + } + return new DefaultGenAiObservation(exchange, context, tracer, meterRegistry); + } + + private static MeterRegistry resolveMeterRegistry(CamelContext camelContext) { + MeterRegistry registry = CamelContextHelper.findSingleByType(camelContext, MeterRegistry.class); + if (registry != null) { + return registry; + } + return camelContext.getRegistry().lookupByNameAndType("metricsRegistry", MeterRegistry.class); + } + + private static final class DefaultGenAiObservation implements GenAiObservation { + + private final Exchange exchange; + private final GenAiObservationContext context; + private final Tracer tracer; + private final MeterRegistry meterRegistry; + private final long startNanos; + private Span span; + private GenAiUsage usage; + private Throwable error; + private boolean closed; + + private DefaultGenAiObservation( + Exchange exchange, GenAiObservationContext context, Tracer tracer, + MeterRegistry meterRegistry) { + this.exchange = exchange; + this.context = context; + this.tracer = tracer; + this.meterRegistry = meterRegistry; + this.startNanos = System.nanoTime(); + startSpan(); + } + + private void startSpan() { + if (tracer == null || tracer.getSpanLifecycleManager() == null) { + return; + } + SpanStorageManagerExchange storage = new SpanStorageManagerExchange(); + Span parent = storage.peek(exchange); + SpanLifecycleManager lifecycleManager = tracer.getSpanLifecycleManager(); + span = lifecycleManager.create(context.spanName(), INTERNAL_SPAN_KIND, parent, null); + lifecycleManager.activate(span); + applyContextAttributes(span, context, null, null); + } + + @Override + public void recordSuccess(GenAiUsage usage) { + this.usage = usage; + } + + @Override + public void recordError(Throwable error) { + this.error = error; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + closeSpan(); + recordMetrics(); + } + + private void closeSpan() { + if (span == null || tracer == null || tracer.getSpanLifecycleManager() == null) { + return; + } + SpanLifecycleManager lifecycleManager = tracer.getSpanLifecycleManager(); + if (error != null) { + span.setError(true); + span.setTag(GenAiAttributes.ERROR_TYPE, error.getClass().getSimpleName()); + } + if (usage != null) { + applyContextAttributes(span, context, usage, error == null ? usage.responseModel() : null); + } + lifecycleManager.deactivate(span); + lifecycleManager.close(span); + span = null; + } + + private void recordMetrics() { + if (meterRegistry == null) { + return; + } + Iterable baseTags = baseTags(context, error); + Timer.builder(GenAiMetrics.CLIENT_OPERATION) + .tags(baseTags) + .register(meterRegistry) + .record(System.nanoTime() - startNanos, java.util.concurrent.TimeUnit.NANOSECONDS); + + if (usage != null) { + recordTokenCounter(usage.inputTokens(), GenAiMetrics.TOKEN_TYPE_INPUT); + recordTokenCounter(usage.outputTokens(), GenAiMetrics.TOKEN_TYPE_OUTPUT); + } + } + + private void recordTokenCounter(Integer tokens, String tokenType) { + if (tokens == null || tokens <= 0) { + return; + } + Counter.builder(GenAiMetrics.CLIENT_TOKEN_USAGE) + .tags(baseTags(context, error)) + .tag(GenAiMetrics.TAG_TOKEN_TYPE, tokenType) + .register(meterRegistry) + .increment(tokens); + } + + private static Iterable baseTags(GenAiObservationContext context, Throwable error) { + java.util.List tags = new java.util.ArrayList<>(); + tags.add(Tag.of(GenAiMetrics.TAG_OPERATION_NAME, context.operationName().value())); + tags.add(Tag.of(GenAiMetrics.TAG_SYSTEM, nullToUnknown(context.system()))); + tags.add(Tag.of(GenAiMetrics.TAG_REQUEST_MODEL, nullToUnknown(context.requestModel()))); + if (ObjectHelper.isNotEmpty(context.componentScheme())) { + tags.add(Tag.of(GenAiMetrics.TAG_CAMEL_COMPONENT, context.componentScheme())); + } + if (error != null) { + tags.add(Tag.of(GenAiMetrics.TAG_ERROR_TYPE, error.getClass().getSimpleName())); + } + return tags; + } + + private static void applyContextAttributes( + Span span, GenAiObservationContext context, GenAiUsage usage, String responseModel) { + span.setTag(GenAiAttributes.OPERATION_NAME, context.operationName().value()); + span.setTag(GenAiAttributes.SYSTEM, nullToUnknown(context.system())); + span.setTag(GenAiAttributes.REQUEST_MODEL, nullToUnknown(context.requestModel())); + if (ObjectHelper.isNotEmpty(context.componentScheme())) { + span.setTag(GenAiAttributes.CAMEL_COMPONENT, context.componentScheme()); + } + if (responseModel != null && !responseModel.isBlank()) { + span.setTag(GenAiAttributes.RESPONSE_MODEL, responseModel); + } + if (usage != null) { + if (usage.inputTokens() != null) { + span.setTag(GenAiAttributes.INPUT_TOKENS, usage.inputTokens().toString()); + } + if (usage.outputTokens() != null) { + span.setTag(GenAiAttributes.OUTPUT_TOKENS, usage.outputTokens().toString()); + } + if (usage.finishReason() != null) { + span.setTag(GenAiAttributes.FINISH_REASONS, usage.finishReason()); + } + if (usage.responseModel() != null && !usage.responseModel().isBlank()) { + span.setTag(GenAiAttributes.RESPONSE_MODEL, usage.responseModel()); + } + } + } + + private static String nullToUnknown(String value) { + return value == null || value.isBlank() ? "unknown" : value; + } + } + + private static final class NoopGenAiObservation implements GenAiObservation { + @Override + public void recordSuccess(GenAiUsage usage) { + // noop + } + + @Override + public void recordError(Throwable error) { + // noop + } + + @Override + public void close() { + // noop + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java new file mode 100644 index 0000000000000..3f6634808220e --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java @@ -0,0 +1,31 @@ +/* + * 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; + +/** + * Global configuration for GenAI observability. + */ +public final class GenAiObservabilityProperties { + + /** + * Global property to enable or disable GenAI observability across all AI components. Default is {@code true}. + */ + public static final String ENABLED = "camel.ai.observability.enabled"; + + private GenAiObservabilityProperties() { + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java new file mode 100644 index 0000000000000..57aff648cefec --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * Active GenAI operation observation. Close when the LLM call completes. + */ +public interface GenAiObservation extends AutoCloseable { + + /** + * Records a successful GenAI operation with usage metadata. + */ + void recordSuccess(GenAiUsage usage); + + /** + * Records a failed GenAI operation. + */ + void recordError(Throwable error); + + @Override + void close(); +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java new file mode 100644 index 0000000000000..015a23337cfd0 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java @@ -0,0 +1,91 @@ +/* + * 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; + +/** + * Context for a single GenAI client operation observation. + */ +public final class GenAiObservationContext { + + private final GenAiOperationName operationName; + private final String system; + private final String requestModel; + private final String componentScheme; + + private GenAiObservationContext(Builder builder) { + this.operationName = builder.operationName; + this.system = builder.system; + this.requestModel = builder.requestModel; + this.componentScheme = builder.componentScheme; + } + + public GenAiOperationName operationName() { + return operationName; + } + + public String system() { + return system; + } + + public String requestModel() { + return requestModel; + } + + public String componentScheme() { + return componentScheme; + } + + public String spanName() { + String model = requestModel != null && !requestModel.isBlank() ? requestModel : "unknown"; + return operationName.value() + " " + model; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private GenAiOperationName operationName = GenAiOperationName.CHAT; + private String system = "unknown"; + private String requestModel; + private String componentScheme; + + public Builder operationName(GenAiOperationName operationName) { + this.operationName = operationName; + return this; + } + + public Builder system(String system) { + this.system = system; + return this; + } + + public Builder requestModel(String requestModel) { + this.requestModel = requestModel; + return this; + } + + public Builder componentScheme(String componentScheme) { + this.componentScheme = componentScheme; + return this; + } + + public GenAiObservationContext build() { + return new GenAiObservationContext(this); + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java new file mode 100644 index 0000000000000..b6334b6c510c4 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * GenAI operation names from the OpenTelemetry semantic conventions. + */ +public enum GenAiOperationName { + CHAT("chat"), + EMBEDDINGS("embeddings"), + GENERATE_CONTENT("generate_content"); + + private final String value; + + GenAiOperationName(String value) { + this.value = value; + } + + public String value() { + return value; + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java new file mode 100644 index 0000000000000..a21f66397bd8b --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * Token usage and completion metadata captured after a GenAI operation. + */ +public record GenAiUsage( + Integer inputTokens, + Integer outputTokens, + String finishReason, + String responseModel) { + + public static GenAiUsage of(Integer inputTokens, Integer outputTokens, Object finishReason, String responseModel) { + String reason = finishReason == null ? null : finishReason.toString(); + return new GenAiUsage(inputTokens, outputTokens, reason, responseModel); + } +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java new file mode 100644 index 0000000000000..3669eb020b3d2 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java @@ -0,0 +1,57 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenAiModelResolverTest { + + @Test + void shouldResolveOpenAiProvider() { + assertThat(GenAiModelResolver.resolveSystem(new FakeOpenAiModel())).isEqualTo("openai"); + } + + @Test + void shouldResolveOllamaProvider() { + assertThat(GenAiModelResolver.resolveSystem(new FakeOllamaModel())).isEqualTo("ollama"); + } + + @Test + void shouldResolveModelNameFromMethod() { + assertThat(GenAiModelResolver.resolveModelName(new FakeOpenAiModel())).isEqualTo("gpt-4o"); + } + + @Test + void shouldReturnUnknownForNullModel() { + assertThat(GenAiModelResolver.resolveSystem(null)).isEqualTo("unknown"); + assertThat(GenAiModelResolver.resolveModelName(null)).isEqualTo("unknown"); + } + + static class FakeOpenAiModel { + public String modelName() { + return "gpt-4o"; + } + } + + static class FakeOllamaModel { + public String getModelName() { + return "llama3"; + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java new file mode 100644 index 0000000000000..1c27970517d4d --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java @@ -0,0 +1,96 @@ +/* + * 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.core.instrument.simple.SimpleMeterRegistry; +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; +import org.apache.camel.support.DefaultExchange; +import org.apache.camel.test.junit6.ExchangeTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenAiObservabilityTest extends ExchangeTestSupport { + + @Test + void shouldBeEnabledByDefault() { + assertThat(GenAiObservability.isEnabled(context)).isTrue(); + } + + @Test + void shouldDisableWhenPropertyIsFalse() { + java.util.Properties properties = new java.util.Properties(); + properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); + context.getPropertiesComponent().setOverrideProperties(properties); + assertThat(GenAiObservability.isEnabled(context)).isFalse(); + } + + @Test + void shouldRecordMicrometerMetricsWhenRegistryPresent() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + context.getRegistry().bind("metricsRegistry", registry); + + Exchange exchange = new DefaultExchange(context); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system("openai") + .requestModel("gpt-4o") + .componentScheme("langchain4j-chat") + .build(); + + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + observation.recordSuccess(GenAiUsage.of(10, 5, "stop", "gpt-4o")); + observation.close(); + + assertThat(registry.find(GenAiMetrics.CLIENT_OPERATION).timer()).isNotNull(); + assertThat(registry.find(GenAiMetrics.CLIENT_TOKEN_USAGE) + .tag(GenAiMetrics.TAG_TOKEN_TYPE, GenAiMetrics.TOKEN_TYPE_INPUT) + .counter() + .count()).isEqualTo(10); + assertThat(registry.find(GenAiMetrics.CLIENT_TOKEN_USAGE) + .tag(GenAiMetrics.TAG_TOKEN_TYPE, GenAiMetrics.TOKEN_TYPE_OUTPUT) + .counter() + .count()).isEqualTo(5); + } + + @Test + void shouldReturnNoopWhenDisabled() { + java.util.Properties properties = new java.util.Properties(); + properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); + context.getPropertiesComponent().setOverrideProperties(properties); + + Exchange exchange = new DefaultExchange(context); + GenAiObservation observation = GenAiObservability.start(exchange, GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .requestModel("gpt-4o") + .build()); + + observation.recordSuccess(GenAiUsage.of(1, 1, "stop", "gpt-4o")); + observation.close(); + + assertThat(registryBeanCount(context)).isZero(); + } + + private static int registryBeanCount(CamelContext context) { + SimpleMeterRegistry registry = context.getRegistry().lookupByNameAndType("metricsRegistry", SimpleMeterRegistry.class); + if (registry == null) { + return 0; + } + return registry.getMeters().size(); + } +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservationContextTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservationContextTest.java new file mode 100644 index 0000000000000..a9c2fc38beeff --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservationContextTest.java @@ -0,0 +1,43 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class GenAiObservationContextTest { + + @Test + void shouldBuildSpanNameFromOperationAndModel() { + GenAiObservationContext context = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .requestModel("gpt-4o") + .build(); + + assertThat(context.spanName()).isEqualTo("chat gpt-4o"); + } + + @Test + void shouldUseUnknownWhenModelMissing() { + GenAiObservationContext context = GenAiObservationContext.builder() + .operationName(GenAiOperationName.EMBEDDINGS) + .build(); + + assertThat(context.spanName()).isEqualTo("embeddings unknown"); + } +} diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index c10f933ffa7d7..49e289f28c4a9 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -56,6 +56,12 @@ public class Headers { @Metadata(description = "The Total Token Count.", javaType = "int") public static final String TOTAL_TOKEN_COUNT = "CamelLangChain4jAgentTotalTokenCount"; + @Metadata(description = "The request model name.", javaType = "String") + public static final String REQUEST_MODEL = "CamelLangChain4jAgentRequestModel"; + + @Metadata(description = "The response model name.", javaType = "String") + public static final String RESPONSE_MODEL = "CamelLangChain4jAgentResponseModel"; + @Metadata(description = "RAG sources retrieved during agent invocation.", javaType = "java.util.List") public static final String SOURCES = "CamelLangChain4jAgentSources"; diff --git a/components/camel-ai/camel-langchain4j-agent/pom.xml b/components/camel-ai/camel-langchain4j-agent/pom.xml index 336db48bdf67a..74f5da4faa9f2 100644 --- a/components/camel-ai/camel-langchain4j-agent/pom.xml +++ b/components/camel-ai/camel-langchain4j-agent/pom.xml @@ -70,6 +70,11 @@ camel-ai-tool ${project.version} + + org.apache.camel + camel-ai-observability + ${project.version} + org.apache.camel diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index 69f27508f5396..e32df68f75a03 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -47,8 +47,10 @@ "CamelLangChain4jAgentInputTokenCount": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#INPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentOutputTokenCount": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentTotalTokenCount": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOTAL_TOKEN_COUNT" }, - "CamelLangChain4jAgentSources": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, + "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, + "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 197481eb90543..96a963fc98f87 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -49,6 +49,12 @@ import org.apache.camel.Exchange; import org.apache.camel.Message; import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.ai.observability.GenAiModelResolver; +import org.apache.camel.component.ai.observability.GenAiObservability; +import org.apache.camel.component.ai.observability.GenAiObservation; +import org.apache.camel.component.ai.observability.GenAiObservationContext; +import org.apache.camel.component.ai.observability.GenAiOperationName; +import org.apache.camel.component.ai.observability.GenAiUsage; import org.apache.camel.component.ai.tool.AiToolExecutor; import org.apache.camel.component.ai.tool.AiToolRegistry; import org.apache.camel.component.ai.tool.AiToolResult; @@ -141,14 +147,46 @@ public void process(Exchange exchange) throws Exception { AiAgentBody aiAgentBody = exchange.getMessage().getMandatoryBody(AiAgentBody.class); ToolProvider toolProvider = createComposedToolProvider(tags, exchange); - Result result = agent.chat(aiAgentBody, toolProvider); - exchange.getMessage().setBody(result.content()); - populateResultHeaders(result, exchange); + Object chatModel = resolveChatModel(agent); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.GENERATE_CONTENT) + .system(GenAiModelResolver.resolveSystem(chatModel)) + .requestModel(GenAiModelResolver.resolveModelName(chatModel)) + .componentScheme("langchain4j-agent") + .build(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + Result result = agent.chat(aiAgentBody, toolProvider); + exchange.getMessage().setBody(result.content()); + populateResultHeaders(result, exchange, observationContext.requestModel()); + observation.recordSuccess(GenAiUsage.of( + result.tokenUsage() != null ? result.tokenUsage().inputTokenCount() : null, + result.tokenUsage() != null ? result.tokenUsage().outputTokenCount() : null, + result.finishReason(), + observationContext.requestModel())); + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); + } + } + + private Object resolveChatModel(Agent agent) { + if (endpoint.getConfiguration().getAgentConfiguration() != null) { + return endpoint.getConfiguration().getAgentConfiguration().getChatModel(); + } + return agent; } - private void populateResultHeaders(Result result, Exchange exchange) { + private void populateResultHeaders(Result result, Exchange exchange, String requestModel) { Message message = exchange.getMessage(); + if (requestModel != null) { + message.setHeader(Headers.REQUEST_MODEL, requestModel); + message.setHeader(Headers.RESPONSE_MODEL, requestModel); + } + if (result.finishReason() != null) { message.setHeader(Headers.FINISH_REASON, result.finishReason()); } diff --git a/components/camel-ai/camel-langchain4j-chat/pom.xml b/components/camel-ai/camel-langchain4j-chat/pom.xml index d964c3f866567..8feb7a5cc1a45 100644 --- a/components/camel-ai/camel-langchain4j-chat/pom.xml +++ b/components/camel-ai/camel-langchain4j-chat/pom.xml @@ -63,6 +63,12 @@ ${jackson2-version} + + org.apache.camel + camel-ai-observability + ${project.version} + + org.apache.camel diff --git a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatHeaders.java b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatHeaders.java index cf4a556f3854f..2da957bf99256 100644 --- a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatHeaders.java +++ b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatHeaders.java @@ -36,4 +36,10 @@ public class LangChain4jChatHeaders { @Metadata(description = "The Total Token Count.", javaType = "int") public static final String TOTAL_TOKEN_COUNT = "CamelLangChain4jChatTotalTokenCount"; + + @Metadata(description = "The request model name.", javaType = "String") + public static final String REQUEST_MODEL = "CamelLangChain4jChatRequestModel"; + + @Metadata(description = "The response model name.", javaType = "String") + public static final String RESPONSE_MODEL = "CamelLangChain4jChatResponseModel"; } diff --git a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java index 5a66fd569add6..2c1ff25e99dda 100644 --- a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java +++ b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java @@ -34,6 +34,12 @@ import org.apache.camel.InvalidPayloadException; import org.apache.camel.Message; import org.apache.camel.NoSuchHeaderException; +import org.apache.camel.component.ai.observability.GenAiModelResolver; +import org.apache.camel.component.ai.observability.GenAiObservability; +import org.apache.camel.component.ai.observability.GenAiObservation; +import org.apache.camel.component.ai.observability.GenAiObservationContext; +import org.apache.camel.component.ai.observability.GenAiOperationName; +import org.apache.camel.component.ai.observability.GenAiUsage; import org.apache.camel.support.DefaultProducer; import org.apache.camel.util.ObjectHelper; @@ -112,9 +118,13 @@ private void populateResponse(String response, Exchange exchange) { exchange.getMessage().setBody(response); } - private void populateTokenUsageHeaders(ChatResponse chatResponse, Exchange exchange) { + private void populateTokenUsageHeaders(ChatResponse chatResponse, Exchange exchange, String requestModel) { Message message = exchange.getMessage(); + if (requestModel != null) { + message.setHeader(LangChain4jChatHeaders.REQUEST_MODEL, requestModel); + } + if (chatResponse.finishReason() != null) { message.setHeader(LangChain4jChatHeaders.FINISH_REASON, chatResponse.finishReason()); } @@ -124,6 +134,11 @@ private void populateTokenUsageHeaders(ChatResponse chatResponse, Exchange excha message.setHeader(LangChain4jChatHeaders.OUTPUT_TOKEN_COUNT, chatResponse.tokenUsage().outputTokenCount()); message.setHeader(LangChain4jChatHeaders.TOTAL_TOKEN_COUNT, chatResponse.tokenUsage().totalTokenCount()); } + + String responseModel = resolveResponseModel(chatResponse, requestModel); + if (responseModel != null) { + message.setHeader(LangChain4jChatHeaders.RESPONSE_MODEL, responseModel); + } } /** @@ -134,10 +149,23 @@ private void populateTokenUsageHeaders(ChatResponse chatResponse, Exchange excha */ private String sendChatMessage(ChatMessage chatMessage, Exchange exchange) { var augmentedChatMessage = addAugmentedData(chatMessage, exchange); - - ChatResponse chatResponse = this.chatModel.chat(augmentedChatMessage); - populateTokenUsageHeaders(chatResponse, exchange); - return extractAiResponse(chatResponse.aiMessage()); + GenAiObservationContext observationContext = buildObservationContext(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + ChatResponse chatResponse = this.chatModel.chat(augmentedChatMessage); + populateTokenUsageHeaders(chatResponse, exchange, observationContext.requestModel()); + observation.recordSuccess(GenAiUsage.of( + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, + chatResponse.finishReason(), + resolveResponseModel(chatResponse, observationContext.requestModel()))); + return extractAiResponse(chatResponse.aiMessage()); + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); + } } /** @@ -182,10 +210,38 @@ private String sendListChatMessage(List chatMessages, Exchange exch } - ChatResponse chatResponse = this.chatModel.chat(chatMessages); - populateTokenUsageHeaders(chatResponse, exchange); - response = chatResponse.aiMessage(); - return extractAiResponse(response); + GenAiObservationContext observationContext = buildObservationContext(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + ChatResponse chatResponse = this.chatModel.chat(chatMessages); + populateTokenUsageHeaders(chatResponse, exchange, observationContext.requestModel()); + observation.recordSuccess(GenAiUsage.of( + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, + chatResponse.finishReason(), + resolveResponseModel(chatResponse, observationContext.requestModel()))); + response = chatResponse.aiMessage(); + return extractAiResponse(response); + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); + } + } + + private GenAiObservationContext buildObservationContext() { + String requestModel = GenAiModelResolver.resolveModelName(chatModel); + return GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system(GenAiModelResolver.resolveSystem(chatModel)) + .requestModel(requestModel) + .componentScheme("langchain4j-chat") + .build(); + } + + private static String resolveResponseModel(ChatResponse chatResponse, String requestModel) { + return requestModel; } private String extractAiResponse(AiMessage response) { diff --git a/components/camel-ai/camel-langchain4j-embeddings/pom.xml b/components/camel-ai/camel-langchain4j-embeddings/pom.xml index 0a7f70cf635e0..8ea74e82ee59f 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/pom.xml +++ b/components/camel-ai/camel-langchain4j-embeddings/pom.xml @@ -66,6 +66,12 @@ ${langchain4j-beta-version} + + org.apache.camel + camel-ai-observability + ${project.version} + + org.apache.camel diff --git a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsHeaders.java b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsHeaders.java index 396808aaa6918..0849a3cbcd215 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsHeaders.java +++ b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsHeaders.java @@ -32,6 +32,12 @@ public class LangChain4jEmbeddingsHeaders { @Metadata(description = "The Total Token Count.", javaType = "int") public static final String TOTAL_TOKEN_COUNT = "CamelLangChain4jEmbeddingsTotalTokenCount"; + @Metadata(description = "The request model name.", javaType = "String") + public static final String REQUEST_MODEL = "CamelLangChain4jEmbeddingsRequestModel"; + + @Metadata(description = "The response model name.", javaType = "String") + public static final String RESPONSE_MODEL = "CamelLangChain4jEmbeddingsResponseModel"; + @Metadata(description = "Embedding representation of a text", javaType = "dev.langchain4j.data.embedding.Embedding") public static final String EMBEDDING = CamelLangchain4jAttributes.CAMEL_LANGCHAIN4J_EMBEDDING; diff --git a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java index d1ad52fbf023a..da016be38b5e4 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java +++ b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java @@ -22,6 +22,12 @@ import dev.langchain4j.model.output.Response; import org.apache.camel.Exchange; import org.apache.camel.Message; +import org.apache.camel.component.ai.observability.GenAiModelResolver; +import org.apache.camel.component.ai.observability.GenAiObservability; +import org.apache.camel.component.ai.observability.GenAiObservation; +import org.apache.camel.component.ai.observability.GenAiObservationContext; +import org.apache.camel.component.ai.observability.GenAiOperationName; +import org.apache.camel.component.ai.observability.GenAiUsage; import org.apache.camel.support.DefaultProducer; public class LangChain4jEmbeddingsProducer extends DefaultProducer { @@ -38,8 +44,35 @@ public LangChain4jEmbeddingsEndpoint getEndpoint() { public void process(Exchange exchange) throws Exception { final TextSegment in = exchange.getMessage().getMandatoryBody(TextSegment.class); final EmbeddingModel model = getEndpoint().getConfiguration().getEmbeddingModel(); - final Response result = model.embed(in); - final Message message = exchange.getMessage(); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.EMBEDDINGS) + .system(GenAiModelResolver.resolveSystem(model)) + .requestModel(GenAiModelResolver.resolveModelName(model)) + .componentScheme("langchain4j-embeddings") + .build(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + final Response result = model.embed(in); + populateHeaders(exchange.getMessage(), result, in, observationContext.requestModel()); + observation.recordSuccess(GenAiUsage.of( + result.tokenUsage() != null ? result.tokenUsage().inputTokenCount() : null, + result.tokenUsage() != null ? result.tokenUsage().outputTokenCount() : null, + result.finishReason(), + observationContext.requestModel())); + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); + } + } + + private static void populateHeaders( + Message message, Response result, TextSegment textSegment, String requestModel) { + if (requestModel != null) { + message.setHeader(LangChain4jEmbeddingsHeaders.REQUEST_MODEL, requestModel); + message.setHeader(LangChain4jEmbeddingsHeaders.RESPONSE_MODEL, requestModel); + } if (result.finishReason() != null) { message.setHeader(LangChain4jEmbeddingsHeaders.FINISH_REASON, result.finishReason()); @@ -52,7 +85,7 @@ public void process(Exchange exchange) throws Exception { } message.setHeader(LangChain4jEmbeddingsHeaders.VECTOR, result.content().vector()); - message.setHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT, in); + message.setHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT, textSegment); message.setHeader(LangChain4jEmbeddingsHeaders.EMBEDDING, result.content()); } } diff --git a/components/camel-ai/camel-langchain4j-tools/pom.xml b/components/camel-ai/camel-langchain4j-tools/pom.xml index 8df6fd58d29d9..28a8f97d8292e 100644 --- a/components/camel-ai/camel-langchain4j-tools/pom.xml +++ b/components/camel-ai/camel-langchain4j-tools/pom.xml @@ -61,6 +61,12 @@ ${langchain4j-version} + + org.apache.camel + camel-ai-observability + ${project.version} + + org.apache.camel diff --git a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsHeaders.java b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsHeaders.java index c70bd7ff4dd6d..b3a49df62f990 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsHeaders.java +++ b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsHeaders.java @@ -32,4 +32,10 @@ public class LangChain4jToolsHeaders { @Metadata(description = "The Total Token Count.", javaType = "int") public static final String TOTAL_TOKEN_COUNT = "CamelLangChain4jToolsTotalTokenCount"; + + @Metadata(description = "The request model name.", javaType = "String") + public static final String REQUEST_MODEL = "CamelLangChain4jToolsRequestModel"; + + @Metadata(description = "The response model name.", javaType = "String") + public static final String RESPONSE_MODEL = "CamelLangChain4jToolsResponseModel"; } diff --git a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java index e4c43128221d1..09c639808ee4b 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java +++ b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java @@ -47,6 +47,12 @@ import org.apache.camel.Message; import org.apache.camel.RuntimeCamelException; import org.apache.camel.TypeConverter; +import org.apache.camel.component.ai.observability.GenAiModelResolver; +import org.apache.camel.component.ai.observability.GenAiObservability; +import org.apache.camel.component.ai.observability.GenAiObservation; +import org.apache.camel.component.ai.observability.GenAiObservationContext; +import org.apache.camel.component.ai.observability.GenAiOperationName; +import org.apache.camel.component.ai.observability.GenAiUsage; import org.apache.camel.component.langchain4j.tools.spec.CamelToolExecutorCache; import org.apache.camel.component.langchain4j.tools.spec.CamelToolSpecification; import org.apache.camel.support.DefaultProducer; @@ -383,21 +389,40 @@ private ChatResponse chatWithLLM(List chatMessages, ToolPair toolPa requestBuilder.toolSpecifications(toolPair.toolSpecifications()); } - // build request ChatRequest chatRequest = requestBuilder.build(); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system(GenAiModelResolver.resolveSystem(chatModel)) + .requestModel(GenAiModelResolver.resolveModelName(chatModel)) + .componentScheme("langchain4j-tools") + .build(); + exchange.getMessage().setHeader(LangChain4jToolsHeaders.REQUEST_MODEL, observationContext.requestModel()); - // generate response - ChatResponse chatResponse = this.chatModel.chat(chatRequest); - - AiMessage aiMessage = chatResponse.aiMessage(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + ChatResponse chatResponse = this.chatModel.chat(chatRequest); + observation.recordSuccess(GenAiUsage.of( + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, + chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, + chatResponse.finishReason(), + observationContext.requestModel())); + exchange.getMessage().setHeader(LangChain4jToolsHeaders.RESPONSE_MODEL, observationContext.requestModel()); + + AiMessage aiMessage = chatResponse.aiMessage(); + + if (!aiMessage.hasToolExecutionRequests()) { + exchange.getMessage().setHeader(LangChain4jTools.NO_TOOLS_CALLED_HEADER, Boolean.TRUE); + return chatResponse; + } - if (!aiMessage.hasToolExecutionRequests()) { - exchange.getMessage().setHeader(LangChain4jTools.NO_TOOLS_CALLED_HEADER, Boolean.TRUE); + chatMessages.add(aiMessage); return chatResponse; + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); } - - chatMessages.add(aiMessage); - return chatResponse; } /** diff --git a/components/camel-ai/camel-openai/pom.xml b/components/camel-ai/camel-openai/pom.xml index 9bafddb02bd22..564dd753d6a5e 100644 --- a/components/camel-ai/camel-openai/pom.xml +++ b/components/camel-ai/camel-openai/pom.xml @@ -59,6 +59,12 @@ ${mcp-java-sdk-version} + + org.apache.camel + camel-ai-observability + ${project.version} + + org.apache.camel diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index 5820cbfbee7c4..b6b6d562e7039 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -56,6 +56,11 @@ import org.apache.camel.Exchange; import org.apache.camel.Message; import org.apache.camel.WrappedFile; +import org.apache.camel.component.ai.observability.GenAiObservability; +import org.apache.camel.component.ai.observability.GenAiObservation; +import org.apache.camel.component.ai.observability.GenAiObservationContext; +import org.apache.camel.component.ai.observability.GenAiOperationName; +import org.apache.camel.component.ai.observability.GenAiUsage; import org.apache.camel.spi.Synchronization; import org.apache.camel.support.DefaultAsyncProducer; import org.apache.camel.support.ResourceHelper; @@ -462,7 +467,7 @@ private void processNonStreaming(Exchange exchange, ChatCompletionCreateParams p private void processNonStreamingSimple( Exchange exchange, ChatCompletionCreateParams params, OpenAIConfiguration config) throws Exception { - ChatCompletion response = getEndpoint().getClient().chat().completions().create(params); + ChatCompletion response = createChatCompletion(exchange, params); if (config.isStoreFullResponse()) { exchange.setProperty(OpenAIConstants.RESPONSE, response); } @@ -498,7 +503,7 @@ private void processNonStreamingAgentic( int iteration = 0; while (iteration < maxIterations) { - ChatCompletion response = getEndpoint().getClient().chat().completions().create(paramsBuilder.build()); + ChatCompletion response = createChatCompletion(exchange, paramsBuilder.build()); tokenTracker.addUsage(response); setAgenticTokenHeaders(exchange.getMessage(), tokenTracker); @@ -655,6 +660,32 @@ private static String getFinishReasonString(ChatCompletion.Choice choice) { .orElse("stop"); } + private ChatCompletion createChatCompletion(Exchange exchange, ChatCompletionCreateParams params) { + String requestModel = params.model().toString(); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system("openai") + .requestModel(requestModel) + .componentScheme("openai") + .build(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + try { + ChatCompletion response = getEndpoint().getClient().chat().completions().create(params); + CompletionUsage usage = response.usage().orElse(null); + observation.recordSuccess(GenAiUsage.of( + usage != null ? (int) usage.promptTokens() : null, + usage != null ? (int) usage.completionTokens() : null, + response.choices().isEmpty() ? null : getFinishReasonString(response.choices().get(0)), + response.model())); + return response; + } catch (RuntimeException e) { + observation.recordError(e); + throw e; + } finally { + observation.close(); + } + } + private void setResponseHeaders(Message message, ChatCompletion response) { message.setHeader(OpenAIConstants.RESPONSE_ID, response.id()); message.setHeader(OpenAIConstants.RESPONSE_MODEL, response.model()); diff --git a/components/camel-ai/pom.xml b/components/camel-ai/pom.xml index 8325bc01e2c0e..b4c738ea6f485 100644 --- a/components/camel-ai/pom.xml +++ b/components/camel-ai/pom.xml @@ -35,6 +35,7 @@ camel-a2a + camel-ai-observability camel-ai-tool camel-chatscript camel-djl diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index a6b8fb0663265..b1737643ef5d8 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -269,6 +269,16 @@ when its package manager is absent, so the same entry point runs on macOS, Linux Windows x64 CI. Validation performs no remote publication and calls no SDKMAN Vendor API; that is the `publish` workflow. +=== camel-ai-observability (GenAI observability) + +LangChain4j and OpenAI producers now emit GenAI observability data (OpenTelemetry span attributes and +Micrometer metrics) when `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath. +Disable globally with `camel.ai.observability.enabled=false` (default is enabled). + +LangChain4j components also expose request/response model names on new exchange headers +(`CamelLangChain4j*RequestModel`, `CamelLangChain4j*ResponseModel`). See +xref:components:ai-observability.adoc[AI Observability] for metric names and span attributes. + === camel-langchain4j-agent The `Agent.chat()` method return type has changed from `String` to `Result` (from `dev.langchain4j.service.Result`). diff --git a/parent/pom.xml b/parent/pom.xml index 1a8f16f90ce72..cca05268746d8 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -717,6 +717,11 @@ camel-activemq6 ${project.version} + + org.apache.camel + camel-ai-observability + ${project.version} + org.apache.camel camel-ai-tool diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java index 678ec0b56e157..48f08652b7dc2 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java @@ -38,7 +38,8 @@ private MojoHelper() { public static List getComponentPath(Path dir) { switch (dir.getFileName().toString()) { case "camel-ai": - return Arrays.asList(dir.resolve("camel-a2a"), dir.resolve("camel-ai-tool"), + return Arrays.asList(dir.resolve("camel-a2a"), dir.resolve("camel-ai-observability"), + dir.resolve("camel-ai-tool"), dir.resolve("camel-chatscript"), dir.resolve("camel-djl"), dir.resolve("camel-huggingface"), dir.resolve("camel-langchain4j-agent"), dir.resolve("camel-langchain4j-chat"), From a2f97eb1e18e14a60483af73d6a9ea4035c006db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 06:07:23 +0000 Subject: [PATCH 02/12] CAMEL-23861: Fix tracer lookup and add OTel span tests Use CamelContext.hasService(Tracer.class) instead of registry lookup so GenAI observability works when camel-opentelemetry2 registers the tracer as a service. Add GenAiObservabilitySpanTest, fix component doc metadata, and clean up imports. Co-authored-by: Omar Atie --- .../src/main/docs/ai-observability.adoc | 15 +- .../ai/observability/GenAiObservability.java | 13 +- .../GenAiObservabilitySpanTest.java | 177 ++++++++++++++++++ .../observability/GenAiObservabilityTest.java | 6 +- .../langchain4j/chat/langchain4j-chat.json | 4 +- .../embeddings/langchain4j-embeddings.json | 8 +- .../langchain4j/tools/langchain4j-tools.json | 4 +- 7 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java 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 941615e43ff0d..290129e311547 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 @@ -1,4 +1,15 @@ -= AI Observability += AI Observability Component +:doctitle: AI Observability +:shortname: ai-observability +:artifactid: camel-ai-observability +:description: GenAI observability support for Camel AI components (OpenTelemetry and Micrometer) +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: +//Manually maintained attributes +:group: AI + +*Since Camel {since}* The `camel-ai-observability` module provides GenAI observability for Camel AI components following the OpenTelemetry semantic conventions for GenAI (stable subset aligned with Spring AI). @@ -24,5 +35,3 @@ 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`) - -See xref:components:ai-observability.adoc[AI Observability] for details. diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java index 8fd61f061dd1f..47a2d846c0a5d 100644 --- a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -16,6 +16,11 @@ */ package org.apache.camel.component.ai.observability; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; @@ -47,7 +52,7 @@ public static boolean isEnabled(CamelContext camelContext) { if (camelContext == null) { return false; } - java.util.Optional property + Optional property = camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED); if (property.isPresent()) { return Boolean.parseBoolean(property.get().trim()); @@ -63,7 +68,7 @@ public static GenAiObservation start(Exchange exchange, GenAiObservationContext if (exchange == null || context == null || !isEnabled(exchange.getContext())) { return NOOP; } - Tracer tracer = CamelContextHelper.findSingleByType(exchange.getContext(), Tracer.class); + Tracer tracer = exchange.getContext().hasService(Tracer.class); MeterRegistry meterRegistry = resolveMeterRegistry(exchange.getContext()); if (tracer == null && meterRegistry == null) { return NOOP; @@ -159,7 +164,7 @@ private void recordMetrics() { Timer.builder(GenAiMetrics.CLIENT_OPERATION) .tags(baseTags) .register(meterRegistry) - .record(System.nanoTime() - startNanos, java.util.concurrent.TimeUnit.NANOSECONDS); + .record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); if (usage != null) { recordTokenCounter(usage.inputTokens(), GenAiMetrics.TOKEN_TYPE_INPUT); @@ -179,7 +184,7 @@ private void recordTokenCounter(Integer tokens, String tokenType) { } private static Iterable baseTags(GenAiObservationContext context, Throwable error) { - java.util.List tags = new java.util.ArrayList<>(); + List tags = new ArrayList<>(); tags.add(Tag.of(GenAiMetrics.TAG_OPERATION_NAME, context.operationName().value())); tags.add(Tag.of(GenAiMetrics.TAG_SYSTEM, nullToUnknown(context.system()))); tags.add(Tag.of(GenAiMetrics.TAG_REQUEST_MODEL, nullToUnknown(context.requestModel()))); diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java new file mode 100644 index 0000000000000..2a99235aea3b5 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilitySpanTest.java @@ -0,0 +1,177 @@ +/* + * 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 org.apache.camel.CamelContext; +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 GenAiObservabilitySpanTest extends ExchangeTestSupport { + + private RecordingTracer tracer; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + tracer = new RecordingTracer(); + CamelContextAware.trySetCamelContext(tracer, context); + tracer.init(context); + return context; + } + + @Test + void shouldCreateSpanWithGenAiAttributesOnSuccess() { + Exchange exchange = new DefaultExchange(context); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system("openai") + .requestModel("gpt-4o") + .componentScheme("langchain4j-chat") + .build(); + + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + observation.recordSuccess(GenAiUsage.of(12, 8, "stop", "gpt-4o-mini")); + observation.close(); + + assertThat(tracer.closedSpans()).hasSize(1); + Map tags = tracer.closedSpans().get(0).tags(); + assertThat(tags.get(GenAiAttributes.OPERATION_NAME)).isEqualTo("chat"); + assertThat(tags.get(GenAiAttributes.SYSTEM)).isEqualTo("openai"); + assertThat(tags.get(GenAiAttributes.REQUEST_MODEL)).isEqualTo("gpt-4o"); + assertThat(tags.get(GenAiAttributes.RESPONSE_MODEL)).isEqualTo("gpt-4o-mini"); + assertThat(tags.get(GenAiAttributes.INPUT_TOKENS)).isEqualTo("12"); + assertThat(tags.get(GenAiAttributes.OUTPUT_TOKENS)).isEqualTo("8"); + assertThat(tags.get(GenAiAttributes.FINISH_REASONS)).isEqualTo("stop"); + assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("langchain4j-chat"); + } + + @Test + void shouldMarkSpanAsErrorWhenFailureRecorded() { + Exchange exchange = new DefaultExchange(context); + GenAiObservation observation = GenAiObservability.start(exchange, GenAiObservationContext.builder() + .operationName(GenAiOperationName.EMBEDDINGS) + .requestModel("text-embedding-3-small") + .build()); + observation.recordError(new IllegalStateException("rate limited")); + observation.close(); + + assertThat(tracer.closedSpans()).hasSize(1); + RecordingSpan span = tracer.closedSpans().get(0); + assertThat(span.error()).isTrue(); + assertThat(span.tags().get(GenAiAttributes.ERROR_TYPE)).isEqualTo("IllegalStateException"); + } + + private static final class RecordingTracer extends Tracer { + + private final List closedSpans = new ArrayList<>(); + + @Override + protected void initTracer() { + setSpanLifecycleManager(new RecordingSpanLifecycleManager()); + } + + List closedSpans() { + return closedSpans; + } + + private final class RecordingSpanLifecycleManager implements SpanLifecycleManager { + + @Override + public Span create(String spanName, String spanKind, Span parent, SpanContextPropagationExtractor extractor) { + return new RecordingSpan(spanName); + } + + @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 String name; + private final Map tags = new HashMap<>(); + private boolean error; + + private RecordingSpan(String name) { + this.name = name; + } + + Map tags() { + return tags; + } + + boolean error() { + return error; + } + + @Override + public void log(Map 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) { + this.error = isError; + } + + String name() { + return name; + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java index 1c27970517d4d..f58cb29dc2ca2 100644 --- a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java @@ -16,6 +16,8 @@ */ package org.apache.camel.component.ai.observability; +import java.util.Properties; + import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; @@ -34,7 +36,7 @@ void shouldBeEnabledByDefault() { @Test void shouldDisableWhenPropertyIsFalse() { - java.util.Properties properties = new java.util.Properties(); + Properties properties = new Properties(); properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); context.getPropertiesComponent().setOverrideProperties(properties); assertThat(GenAiObservability.isEnabled(context)).isFalse(); @@ -70,7 +72,7 @@ void shouldRecordMicrometerMetricsWhenRegistryPresent() { @Test void shouldReturnNoopWhenDisabled() { - java.util.Properties properties = new java.util.Properties(); + Properties properties = new Properties(); properties.setProperty(GenAiObservabilityProperties.ENABLED, "false"); context.getPropertiesComponent().setOverrideProperties(properties); diff --git a/components/camel-ai/camel-langchain4j-chat/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/chat/langchain4j-chat.json b/components/camel-ai/camel-langchain4j-chat/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/chat/langchain4j-chat.json index f0f3b782de729..3cba5f9bbaea7 100644 --- a/components/camel-ai/camel-langchain4j-chat/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/chat/langchain4j-chat.json +++ b/components/camel-ai/camel-langchain4j-chat/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/chat/langchain4j-chat.json @@ -36,7 +36,9 @@ "CamelLangChain4jChatFinishReason": { "index": 2, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.output.FinishReason", "enum": [ "STOP", "LENGTH", "TOOL_EXECUTION", "CONTENT_FILTER", "OTHER" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Finish Reason.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#FINISH_REASON" }, "CamelLangChain4jChatInputTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jChatOutputTokenCount": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#OUTPUT_TOKEN_COUNT" }, - "CamelLangChain4jChatTotalTokenCount": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#TOTAL_TOKEN_COUNT" } + "CamelLangChain4jChatTotalTokenCount": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#TOTAL_TOKEN_COUNT" }, + "CamelLangChain4jChatRequestModel": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#REQUEST_MODEL" }, + "CamelLangChain4jChatResponseModel": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#RESPONSE_MODEL" } }, "properties": { "chatId": { "index": 0, "kind": "path", "displayName": "Chat Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id" }, diff --git a/components/camel-ai/camel-langchain4j-embeddings/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddings/langchain4j-embeddings.json b/components/camel-ai/camel-langchain4j-embeddings/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddings/langchain4j-embeddings.json index e307b3c21cd40..34f131fd0ef2e 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddings/langchain4j-embeddings.json +++ b/components/camel-ai/camel-langchain4j-embeddings/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddings/langchain4j-embeddings.json @@ -34,9 +34,11 @@ "CamelLangChain4jEmbeddingsInputTokenCount": { "index": 1, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jEmbeddingsOutputTokenCount": { "index": 2, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jEmbeddingsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TOTAL_TOKEN_COUNT" }, - "CamelLangChain4jEmbeddingsEmbedding": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.data.embedding.Embedding", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Embedding representation of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#EMBEDDING" }, - "CamelLangChain4jEmbeddingsVector": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "float[]", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A dense vector embedding of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#VECTOR" }, - "CamelLangChain4jEmbeddingsTextSegment": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": " dev.langchain4j.data.segment.TextSegment", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A TextSegment representation of the vector embedding input text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TEXT_SEGMENT" } + "CamelLangChain4jEmbeddingsRequestModel": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#REQUEST_MODEL" }, + "CamelLangChain4jEmbeddingsResponseModel": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#RESPONSE_MODEL" }, + "CamelLangChain4jEmbeddingsEmbedding": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.data.embedding.Embedding", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Embedding representation of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#EMBEDDING" }, + "CamelLangChain4jEmbeddingsVector": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "float[]", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A dense vector embedding of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#VECTOR" }, + "CamelLangChain4jEmbeddingsTextSegment": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": " dev.langchain4j.data.segment.TextSegment", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A TextSegment representation of the vector embedding input text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TEXT_SEGMENT" } }, "properties": { "embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id" }, diff --git a/components/camel-ai/camel-langchain4j-tools/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/tools/langchain4j-tools.json b/components/camel-ai/camel-langchain4j-tools/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/tools/langchain4j-tools.json index ca07a1d700528..e47c58f36c765 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/tools/langchain4j-tools.json +++ b/components/camel-ai/camel-langchain4j-tools/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/tools/langchain4j-tools.json @@ -34,7 +34,9 @@ "CamelLangChain4jToolsFinishReason": { "index": 0, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "dev.langchain4j.model.output.FinishReason", "enum": [ "STOP", "LENGTH", "TOOL_EXECUTION", "CONTENT_FILTER", "OTHER" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Finish Reason.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#FINISH_REASON" }, "CamelLangChain4jToolsInputTokenCount": { "index": 1, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jToolsOutputTokenCount": { "index": 2, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#OUTPUT_TOKEN_COUNT" }, - "CamelLangChain4jToolsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#TOTAL_TOKEN_COUNT" } + "CamelLangChain4jToolsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#TOTAL_TOKEN_COUNT" }, + "CamelLangChain4jToolsRequestModel": { "index": 4, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#REQUEST_MODEL" }, + "CamelLangChain4jToolsResponseModel": { "index": 5, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#RESPONSE_MODEL" } }, "properties": { "toolId": { "index": 0, "kind": "path", "displayName": "Tool Id", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool id" }, From 8154249e8f621195042019c809abe30de85a0f0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 06:23:16 +0000 Subject: [PATCH 03/12] CAMEL-23861: Address review findings for GenAI observability - Resolve agent chat model from AbstractAgent configuration - Use CLIENT span kind and exchange header propagation extractor - Add observability wrapper for OpenAI streaming path Co-authored-by: Omar Atie --- .../ai/observability/GenAiObservability.java | 6 ++++-- .../langchain4j/agent/api/AbstractAgent.java | 8 ++++++++ .../agent/LangChain4jAgentProducer.java | 5 ++++- .../camel/component/openai/OpenAIProducer.java | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java index 47a2d846c0a5d..2efb07ea3264f 100644 --- a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -32,6 +32,7 @@ import org.apache.camel.telemetry.SpanLifecycleManager; import org.apache.camel.telemetry.SpanStorageManagerExchange; import org.apache.camel.telemetry.Tracer; +import org.apache.camel.telemetry.propagation.CamelHeadersSpanContextPropagationExtractor; import org.apache.camel.util.ObjectHelper; /** @@ -39,7 +40,7 @@ */ public final class GenAiObservability { - private static final String INTERNAL_SPAN_KIND = "INTERNAL"; + private static final String CLIENT_SPAN_KIND = "CLIENT"; private static final GenAiObservation NOOP = new NoopGenAiObservation(); private GenAiObservability() { @@ -114,7 +115,8 @@ private void startSpan() { SpanStorageManagerExchange storage = new SpanStorageManagerExchange(); Span parent = storage.peek(exchange); SpanLifecycleManager lifecycleManager = tracer.getSpanLifecycleManager(); - span = lifecycleManager.create(context.spanName(), INTERNAL_SPAN_KIND, parent, null); + var extractor = new CamelHeadersSpanContextPropagationExtractor(exchange.getIn().getHeaders()); + span = lifecycleManager.create(context.spanName(), CLIENT_SPAN_KIND, parent, extractor); lifecycleManager.activate(span); applyContextAttributes(span, context, null, null); } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 34fd61f2a1180..9c89109cf863a 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -24,6 +24,7 @@ import dev.langchain4j.agent.tool.ToolSpecification; import dev.langchain4j.mcp.McpToolProvider; import dev.langchain4j.mcp.client.McpClient; +import dev.langchain4j.model.chat.ChatModel; import dev.langchain4j.model.chat.request.ResponseFormat; import dev.langchain4j.service.AiServices; import dev.langchain4j.service.tool.ToolProvider; @@ -65,6 +66,13 @@ protected AgentConfiguration getConfiguration() { return configuration; } + /** + * Returns the LangChain4j chat model configured for this agent. + */ + public ChatModel getChatModel() { + return configuration.getChatModel(); + } + /** * Returns the MCP tool provider filter from the given agent's configuration. * diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 96a963fc98f87..5abde16183937 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -176,7 +176,10 @@ private Object resolveChatModel(Agent agent) { if (endpoint.getConfiguration().getAgentConfiguration() != null) { return endpoint.getConfiguration().getAgentConfiguration().getChatModel(); } - return agent; + if (agent instanceof AbstractAgent abstractAgent) { + return abstractAgent.getChatModel(); + } + return null; } private void populateResultHeaders(Result result, Exchange exchange, String requestModel) { diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index b6b6d562e7039..075074d65a728 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -607,6 +607,15 @@ private void enforceAgenticTokenBudget( } private void processStreaming(Exchange exchange, ChatCompletionCreateParams params) { + String requestModel = params.model().toString(); + GenAiObservationContext observationContext = GenAiObservationContext.builder() + .operationName(GenAiOperationName.CHAT) + .system("openai") + .requestModel(requestModel) + .componentScheme("openai") + .build(); + GenAiObservation observation = GenAiObservability.start(exchange, observationContext); + // NOTE: the stream is going to be closed after the exchange completes. StreamResponse streamResponse = getEndpoint().getClient().chat().completions() // NOSONAR .createStreaming(params); @@ -619,11 +628,17 @@ private void processStreaming(Exchange exchange, ChatCompletionCreateParams para exchange.getUnitOfWork().addSynchronization(new Synchronization() { @Override public void onComplete(Exchange e) { + observation.recordSuccess(GenAiUsage.of(null, null, null, requestModel)); + observation.close(); safeClose(); } @Override public void onFailure(Exchange e) { + if (e.getException() != null) { + observation.recordError(e.getException()); + } + observation.close(); safeClose(); } From 56e2850e1f62473cad4d50f9173ae50fa3f06868 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 15:55:11 +0000 Subject: [PATCH 04/12] CAMEL-23861: Address davsclaus review feedback on GenAI observability - Mark micrometer-core and langchain4j-core as optional in camel-ai-observability - Add @since 4.22 to AbstractAgent.getChatModel() - Use langchain4j ModelProvider and defaultRequestParameters instead of fragile reflection - Use ChatResponse.modelName() for response model in langchain4j producers - Accumulate OpenAI streaming token usage via stream_options.include_usage Co-authored-by: Cursor --- .../camel-ai/camel-ai-observability/pom.xml | 7 ++ .../ai/observability/GenAiModelResolver.java | 112 ++++++++++++------ .../observability/GenAiModelResolverTest.java | 69 +++++++++-- .../langchain4j/agent/api/AbstractAgent.java | 3 + .../chat/LangChain4jChatProducer.java | 10 +- .../tools/LangChain4jToolsProducer.java | 5 +- .../component/openai/OpenAIProducer.java | 36 +++++- 7 files changed, 179 insertions(+), 63 deletions(-) diff --git a/components/camel-ai/camel-ai-observability/pom.xml b/components/camel-ai/camel-ai-observability/pom.xml index 1cbf3e516cf1e..7770b66860a29 100644 --- a/components/camel-ai/camel-ai-observability/pom.xml +++ b/components/camel-ai/camel-ai-observability/pom.xml @@ -52,6 +52,13 @@ io.micrometer micrometer-core + true + + + dev.langchain4j + langchain4j-core + ${langchain4j-version} + true diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java index ece643c75fa5a..6d09a1e5ace1d 100644 --- a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java @@ -16,7 +16,10 @@ */ package org.apache.camel.component.ai.observability; -import java.lang.reflect.Method; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.embedding.EmbeddingModel; /** * Resolves GenAI provider and model metadata from LangChain4j model beans. @@ -32,60 +35,91 @@ public static String resolveSystem(Object model) { if (model == null) { return UNKNOWN; } - String className = model.getClass().getName().toLowerCase(); - if (className.contains("openai")) { + if (model instanceof ChatModel chatModel) { + return mapProvider(chatModel.provider()); + } + if (model instanceof EmbeddingModel embeddingModel) { + return mapProvider(embeddingModel.provider()); + } + return resolveSystemFromPackage(model.getClass().getPackageName()); + } + + public static String resolveModelName(Object model) { + if (model == null) { + return UNKNOWN; + } + if (model instanceof ChatModel chatModel) { + String modelName = chatModel.defaultRequestParameters().modelName(); + if (modelName != null && !modelName.isBlank()) { + return modelName; + } + } + if (model instanceof EmbeddingModel embeddingModel) { + String modelName = embeddingModel.modelName(); + if (modelName != null && !modelName.isBlank()) { + return modelName; + } + } + return UNKNOWN; + } + + /** + * Resolves the response model from a LangChain4j {@link ChatResponse}, falling back when absent. + */ + public static String resolveResponseModelName(ChatResponse chatResponse, String fallback) { + if (chatResponse == null) { + return fallback; + } + String modelName = chatResponse.modelName(); + return modelName != null && !modelName.isBlank() ? modelName : fallback; + } + + private static String mapProvider(ModelProvider provider) { + if (provider == null) { + return UNKNOWN; + } + return switch (provider) { + case OPEN_AI -> "openai"; + case ANTHROPIC -> "anthropic"; + case OLLAMA -> "ollama"; + case AZURE_OPEN_AI -> "azure.ai.openai"; + case GOOGLE_VERTEX_AI_GEMINI, GOOGLE_VERTEX_AI_ANTHROPIC -> "gcp.vertex_ai"; + case GOOGLE_AI_GEMINI, GOOGLE_GENAI -> "google"; + case MISTRAL_AI -> "mistral_ai"; + case AMAZON_BEDROCK -> "aws.bedrock"; + default -> UNKNOWN; + }; + } + + private static String resolveSystemFromPackage(String packageName) { + if (packageName == null || packageName.isBlank()) { + return UNKNOWN; + } + if (packageName.startsWith("dev.langchain4j.model.openai")) { return "openai"; } - if (className.contains("anthropic")) { + if (packageName.startsWith("dev.langchain4j.model.anthropic")) { return "anthropic"; } - if (className.contains("ollama")) { + if (packageName.startsWith("dev.langchain4j.model.ollama")) { return "ollama"; } - if (className.contains("azure")) { + if (packageName.startsWith("dev.langchain4j.model.azure")) { return "azure.ai.openai"; } - if (className.contains("vertex") || className.contains("google")) { + if (packageName.startsWith("dev.langchain4j.model.vertexai") + || packageName.startsWith("dev.langchain4j.model.google")) { return "gcp.vertex_ai"; } - if (className.contains("mistral")) { + if (packageName.startsWith("dev.langchain4j.model.mistralai")) { return "mistral_ai"; } - if (className.contains("huggingface") || className.contains("hugging")) { + if (packageName.startsWith("dev.langchain4j.model.huggingface")) { return "huggingface"; } - if (className.contains("bedrock") || className.contains("amazon")) { + if (packageName.startsWith("dev.langchain4j.model.bedrock")) { return "aws.bedrock"; } return UNKNOWN; } - - public static String resolveModelName(Object model) { - if (model == null) { - return UNKNOWN; - } - String fromMethod = invokeStringMethod(model, "modelName"); - if (fromMethod != null && !fromMethod.isBlank()) { - return fromMethod; - } - fromMethod = invokeStringMethod(model, "getModelName"); - if (fromMethod != null && !fromMethod.isBlank()) { - return fromMethod; - } - fromMethod = invokeStringMethod(model, "model"); - if (fromMethod != null && !fromMethod.isBlank()) { - return fromMethod; - } - return UNKNOWN; - } - - private static String invokeStringMethod(Object target, String methodName) { - try { - Method method = target.getClass().getMethod(methodName); - Object value = method.invoke(target); - return value != null ? value.toString() : null; - } catch (ReflectiveOperationException e) { - return null; - } - } } diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java index 3669eb020b3d2..43e42806e844f 100644 --- a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java @@ -16,6 +16,13 @@ */ package org.apache.camel.component.ai.observability; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.request.DefaultChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.embedding.EmbeddingModel; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -23,18 +30,32 @@ class GenAiModelResolverTest { @Test - void shouldResolveOpenAiProvider() { - assertThat(GenAiModelResolver.resolveSystem(new FakeOpenAiModel())).isEqualTo("openai"); + void shouldResolveOpenAiProviderFromChatModel() { + assertThat(GenAiModelResolver.resolveSystem(new FakeOpenAiChatModel())).isEqualTo("openai"); } @Test - void shouldResolveOllamaProvider() { - assertThat(GenAiModelResolver.resolveSystem(new FakeOllamaModel())).isEqualTo("ollama"); + void shouldResolveOllamaProviderFromChatModel() { + assertThat(GenAiModelResolver.resolveSystem(new FakeOllamaChatModel())).isEqualTo("ollama"); } @Test - void shouldResolveModelNameFromMethod() { - assertThat(GenAiModelResolver.resolveModelName(new FakeOpenAiModel())).isEqualTo("gpt-4o"); + void shouldResolveModelNameFromChatModelDefaults() { + assertThat(GenAiModelResolver.resolveModelName(new FakeOpenAiChatModel())).isEqualTo("gpt-4o"); + } + + @Test + void shouldResolveResponseModelNameFromChatResponse() { + ChatResponse response = ChatResponse.builder() + .aiMessage(AiMessage.from("ok")) + .modelName("gpt-4o-mini") + .build(); + assertThat(GenAiModelResolver.resolveResponseModelName(response, "gpt-4o")).isEqualTo("gpt-4o-mini"); + } + + @Test + void shouldNotMatchOpenAiFromUnrelatedPackageName() { + assertThat(GenAiModelResolver.resolveSystem(new UnrelatedPackageModel())).isEqualTo("unknown"); } @Test @@ -43,15 +64,39 @@ void shouldReturnUnknownForNullModel() { assertThat(GenAiModelResolver.resolveModelName(null)).isEqualTo("unknown"); } - static class FakeOpenAiModel { - public String modelName() { - return "gpt-4o"; + static class FakeOpenAiChatModel implements ChatModel { + @Override + public ModelProvider provider() { + return ModelProvider.OPEN_AI; + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return DefaultChatRequestParameters.builder().modelName("gpt-4o").build(); } } - static class FakeOllamaModel { - public String getModelName() { - return "llama3"; + static class FakeOllamaChatModel implements ChatModel { + @Override + public ModelProvider provider() { + return ModelProvider.OLLAMA; + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return DefaultChatRequestParameters.builder().modelName("llama3").build(); + } + } + + static class UnrelatedPackageModel implements EmbeddingModel { + @Override + public ModelProvider provider() { + return ModelProvider.OTHER; + } + + @Override + public String modelName() { + return "custom"; } } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 9c89109cf863a..19f277c512702 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -68,6 +68,9 @@ protected AgentConfiguration getConfiguration() { /** * Returns the LangChain4j chat model configured for this agent. + * + * @return the chat model from agent configuration + * @since 4.22 */ public ChatModel getChatModel() { return configuration.getChatModel(); diff --git a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java index 2c1ff25e99dda..4e212db153b9e 100644 --- a/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java +++ b/components/camel-ai/camel-langchain4j-chat/src/main/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatProducer.java @@ -135,7 +135,7 @@ private void populateTokenUsageHeaders(ChatResponse chatResponse, Exchange excha message.setHeader(LangChain4jChatHeaders.TOTAL_TOKEN_COUNT, chatResponse.tokenUsage().totalTokenCount()); } - String responseModel = resolveResponseModel(chatResponse, requestModel); + String responseModel = GenAiModelResolver.resolveResponseModelName(chatResponse, requestModel); if (responseModel != null) { message.setHeader(LangChain4jChatHeaders.RESPONSE_MODEL, responseModel); } @@ -158,7 +158,7 @@ private String sendChatMessage(ChatMessage chatMessage, Exchange exchange) { chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, chatResponse.finishReason(), - resolveResponseModel(chatResponse, observationContext.requestModel()))); + GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel()))); return extractAiResponse(chatResponse.aiMessage()); } catch (RuntimeException e) { observation.recordError(e); @@ -219,7 +219,7 @@ private String sendListChatMessage(List chatMessages, Exchange exch chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, chatResponse.finishReason(), - resolveResponseModel(chatResponse, observationContext.requestModel()))); + GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel()))); response = chatResponse.aiMessage(); return extractAiResponse(response); } catch (RuntimeException e) { @@ -240,10 +240,6 @@ private GenAiObservationContext buildObservationContext() { .build(); } - private static String resolveResponseModel(ChatResponse chatResponse, String requestModel) { - return requestModel; - } - private String extractAiResponse(AiMessage response) { return response == null ? null : response.text(); } diff --git a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java index 09c639808ee4b..061782bc5fe97 100644 --- a/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java +++ b/components/camel-ai/camel-langchain4j-tools/src/main/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsProducer.java @@ -405,8 +405,9 @@ private ChatResponse chatWithLLM(List chatMessages, ToolPair toolPa chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().inputTokenCount() : null, chatResponse.tokenUsage() != null ? chatResponse.tokenUsage().outputTokenCount() : null, chatResponse.finishReason(), - observationContext.requestModel())); - exchange.getMessage().setHeader(LangChain4jToolsHeaders.RESPONSE_MODEL, observationContext.requestModel()); + GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel()))); + exchange.getMessage().setHeader(LangChain4jToolsHeaders.RESPONSE_MODEL, + GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel())); AiMessage aiMessage = chatResponse.aiMessage(); diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index 075074d65a728..9270c3b7a740e 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -26,6 +26,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -47,6 +48,7 @@ import com.openai.models.chat.completions.ChatCompletionMessage; import com.openai.models.chat.completions.ChatCompletionMessageParam; import com.openai.models.chat.completions.ChatCompletionMessageToolCall; +import com.openai.models.chat.completions.ChatCompletionStreamOptions; import com.openai.models.chat.completions.ChatCompletionSystemMessageParam; import com.openai.models.chat.completions.ChatCompletionToolMessageParam; import com.openai.models.chat.completions.ChatCompletionUserMessageParam; @@ -608,6 +610,9 @@ private void enforceAgenticTokenBudget( private void processStreaming(Exchange exchange, ChatCompletionCreateParams params) { String requestModel = params.model().toString(); + ChatCompletionCreateParams streamingParams = params.toBuilder() + .streamOptions(ChatCompletionStreamOptions.builder().includeUsage(true).build()) + .build(); GenAiObservationContext observationContext = GenAiObservationContext.builder() .operationName(GenAiOperationName.CHAT) .system("openai") @@ -618,17 +623,42 @@ private void processStreaming(Exchange exchange, ChatCompletionCreateParams para // NOTE: the stream is going to be closed after the exchange completes. StreamResponse streamResponse = getEndpoint().getClient().chat().completions() // NOSONAR - .createStreaming(params); + .createStreaming(streamingParams); + + AtomicReference usageRef = new AtomicReference<>(); + AtomicReference responseModelRef = new AtomicReference<>(); + Iterator delegate = streamResponse.stream().iterator(); + Iterator it = new Iterator<>() { + @Override + public boolean hasNext() { + return delegate.hasNext(); + } + + @Override + public ChatCompletionChunk next() { + ChatCompletionChunk chunk = delegate.next(); + chunk.usage().ifPresent(usageRef::set); + if (chunk.model() != null && !chunk.model().isBlank()) { + responseModelRef.set(chunk.model()); + } + return chunk; + } + }; // hand Camel an Iterator for streaming EIPs (split, recipientList, etc.) - Iterator it = streamResponse.stream().iterator(); exchange.getMessage().setBody(it); // ensure resp.close() after the Exchange completes (success or failure) exchange.getUnitOfWork().addSynchronization(new Synchronization() { @Override public void onComplete(Exchange e) { - observation.recordSuccess(GenAiUsage.of(null, null, null, requestModel)); + CompletionUsage usage = usageRef.get(); + String responseModel = responseModelRef.get() != null ? responseModelRef.get() : requestModel; + observation.recordSuccess(GenAiUsage.of( + usage != null ? (int) usage.promptTokens() : null, + usage != null ? (int) usage.completionTokens() : null, + null, + responseModel)); observation.close(); safeClose(); } From 6fc563659f339d622ec747d6be3c858264475a6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 17:20:25 +0000 Subject: [PATCH 05/12] CAMEL-23861: Fix docs xref for ai-observability in upgrade guide Add missing others module doc symlinks and correct the upgrade guide xref to components:others:ai-observability.adoc so documentation validation and docs xref-check pass in CI. Co-authored-by: Omar Atie --- .../modules/others/examples/json/ai-observability.json | 1 + docs/components/modules/others/nav.adoc | 1 + docs/components/modules/others/pages/ai-observability.adoc | 1 + .../modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) create mode 120000 docs/components/modules/others/examples/json/ai-observability.json create mode 120000 docs/components/modules/others/pages/ai-observability.adoc diff --git a/docs/components/modules/others/examples/json/ai-observability.json b/docs/components/modules/others/examples/json/ai-observability.json new file mode 120000 index 0000000000000..50a26fada1bf7 --- /dev/null +++ b/docs/components/modules/others/examples/json/ai-observability.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-ai-observability/src/generated/resources/ai-observability.json \ No newline at end of file diff --git a/docs/components/modules/others/nav.adoc b/docs/components/modules/others/nav.adoc index 093949c595f5e..4cb2b9e946252 100644 --- a/docs/components/modules/others/nav.adoc +++ b/docs/components/modules/others/nav.adoc @@ -4,6 +4,7 @@ * xref:others:index.adoc[Miscellaneous Components] ** xref:a2a-consumer.adoc[A2A - Consumer Guide] ** xref:a2a-producer.adoc[A2A - Producer Guide] +*** xref:ai-observability.adoc[AI Observability] ** xref:attachments.adoc[Attachments] ** xref:aws-bedrock-examples.adoc[AWS Bedrock - Examples] ** xref:aws-bedrock-models.adoc[AWS Bedrock - Supported Models] diff --git a/docs/components/modules/others/pages/ai-observability.adoc b/docs/components/modules/others/pages/ai-observability.adoc new file mode 120000 index 0000000000000..aeb24118b2864 --- /dev/null +++ b/docs/components/modules/others/pages/ai-observability.adoc @@ -0,0 +1 @@ +../../../../../components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc \ No newline at end of file diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index b1737643ef5d8..24702775133c5 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -277,7 +277,7 @@ Disable globally with `camel.ai.observability.enabled=false` (default is enabled LangChain4j components also expose request/response model names on new exchange headers (`CamelLangChain4j*RequestModel`, `CamelLangChain4j*ResponseModel`). See -xref:components:ai-observability.adoc[AI Observability] for metric names and span attributes. +xref:components:others:ai-observability.adoc[AI Observability] for metric names and span attributes. === camel-langchain4j-agent From 4004ccef07d4ab356ff990bbfd7d93cc732ec1a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 19:45:30 +0000 Subject: [PATCH 06/12] CAMEL-23861: Fix CI, optional Micrometer, and round-2 review feedback - Commit regenerated catalog and endpoint DSL files for model headers - Load Micrometer support reflectively so optional micrometer-core does not break consumers without Micrometer on the classpath - Omit RESPONSE_MODEL header/span attribute in agent and embeddings producers when the response model is unavailable from the result object - Add LangChain4jChatObservabilityTest for producer-level span/header coverage - Add agent test asserting RESPONSE_MODEL is omitted when unavailable Co-authored-by: Omar Atie --- .../catalog/components/langchain4j-agent.json | 6 +- .../catalog/components/langchain4j-chat.json | 4 +- .../components/langchain4j-embeddings.json | 8 +- .../catalog/components/langchain4j-tools.json | 4 +- .../org/apache/camel/catalog/docs.properties | 1 + .../camel/catalog/docs/ai-observability.adoc | 37 ++++ .../camel-jbang-configuration-metadata.json | 1 + .../apache/camel/catalog/others.properties | 1 + .../catalog/others/ai-observability.json | 15 ++ .../ai/observability/GenAiMetricsBackend.java | 27 +++ .../observability/GenAiMicrometerSupport.java | 97 ++++++++ .../ai/observability/GenAiObservability.java | 76 ++----- .../agent/LangChain4jAgentProducer.java | 3 +- .../LangChain4jAgentResultHeadersTest.java | 19 ++ .../camel-ai/camel-langchain4j-chat/pom.xml | 7 +- .../LangChain4jChatObservabilityTest.java | 207 ++++++++++++++++++ .../LangChain4jEmbeddingsProducer.java | 3 +- ...angChain4jAgentEndpointBuilderFactory.java | 24 ++ ...LangChain4jChatEndpointBuilderFactory.java | 24 ++ ...ain4jEmbeddingsEndpointBuilderFactory.java | 26 +++ ...angChain4jToolsEndpointBuilderFactory.java | 24 ++ 21 files changed, 547 insertions(+), 67 deletions(-) create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/ai-observability.json create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java create mode 100644 components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java create mode 100644 components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatObservabilityTest.java diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index 69f27508f5396..e32df68f75a03 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -47,8 +47,10 @@ "CamelLangChain4jAgentInputTokenCount": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#INPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentOutputTokenCount": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentTotalTokenCount": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOTAL_TOKEN_COUNT" }, - "CamelLangChain4jAgentSources": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, - "CamelLangChain4jAgentToolExecutions": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } + "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, + "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, + "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, + "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } }, "properties": { "agentId": { "index": 0, "kind": "path", "displayName": "Agent Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Agent id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-chat.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-chat.json index f0f3b782de729..3cba5f9bbaea7 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-chat.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-chat.json @@ -36,7 +36,9 @@ "CamelLangChain4jChatFinishReason": { "index": 2, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.model.output.FinishReason", "enum": [ "STOP", "LENGTH", "TOOL_EXECUTION", "CONTENT_FILTER", "OTHER" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Finish Reason.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#FINISH_REASON" }, "CamelLangChain4jChatInputTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jChatOutputTokenCount": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#OUTPUT_TOKEN_COUNT" }, - "CamelLangChain4jChatTotalTokenCount": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#TOTAL_TOKEN_COUNT" } + "CamelLangChain4jChatTotalTokenCount": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#TOTAL_TOKEN_COUNT" }, + "CamelLangChain4jChatRequestModel": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#REQUEST_MODEL" }, + "CamelLangChain4jChatResponseModel": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.chat.LangChain4jChatHeaders#RESPONSE_MODEL" } }, "properties": { "chatId": { "index": 0, "kind": "path", "displayName": "Chat Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddings.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddings.json index e307b3c21cd40..34f131fd0ef2e 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddings.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddings.json @@ -34,9 +34,11 @@ "CamelLangChain4jEmbeddingsInputTokenCount": { "index": 1, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jEmbeddingsOutputTokenCount": { "index": 2, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jEmbeddingsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TOTAL_TOKEN_COUNT" }, - "CamelLangChain4jEmbeddingsEmbedding": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.data.embedding.Embedding", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Embedding representation of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#EMBEDDING" }, - "CamelLangChain4jEmbeddingsVector": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "float[]", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A dense vector embedding of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#VECTOR" }, - "CamelLangChain4jEmbeddingsTextSegment": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": " dev.langchain4j.data.segment.TextSegment", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A TextSegment representation of the vector embedding input text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TEXT_SEGMENT" } + "CamelLangChain4jEmbeddingsRequestModel": { "index": 4, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#REQUEST_MODEL" }, + "CamelLangChain4jEmbeddingsResponseModel": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#RESPONSE_MODEL" }, + "CamelLangChain4jEmbeddingsEmbedding": { "index": 6, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "dev.langchain4j.data.embedding.Embedding", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Embedding representation of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#EMBEDDING" }, + "CamelLangChain4jEmbeddingsVector": { "index": 7, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "float[]", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A dense vector embedding of a text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#VECTOR" }, + "CamelLangChain4jEmbeddingsTextSegment": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": " dev.langchain4j.data.segment.TextSegment", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "A TextSegment representation of the vector embedding input text", "constantName": "org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders#TEXT_SEGMENT" } }, "properties": { "embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding Id", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-tools.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-tools.json index ca07a1d700528..e47c58f36c765 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-tools.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-tools.json @@ -34,7 +34,9 @@ "CamelLangChain4jToolsFinishReason": { "index": 0, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "dev.langchain4j.model.output.FinishReason", "enum": [ "STOP", "LENGTH", "TOOL_EXECUTION", "CONTENT_FILTER", "OTHER" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Finish Reason.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#FINISH_REASON" }, "CamelLangChain4jToolsInputTokenCount": { "index": 1, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Input Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#INPUT_TOKEN_COUNT" }, "CamelLangChain4jToolsOutputTokenCount": { "index": 2, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#OUTPUT_TOKEN_COUNT" }, - "CamelLangChain4jToolsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#TOTAL_TOKEN_COUNT" } + "CamelLangChain4jToolsTotalTokenCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#TOTAL_TOKEN_COUNT" }, + "CamelLangChain4jToolsRequestModel": { "index": 4, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#REQUEST_MODEL" }, + "CamelLangChain4jToolsResponseModel": { "index": 5, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.tools.LangChain4jToolsHeaders#RESPONSE_MODEL" } }, "properties": { "toolId": { "index": 0, "kind": "path", "displayName": "Tool Id", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The tool id" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties index 36ae1f24a7418..1fcad4adc2275 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties @@ -5,6 +5,7 @@ a2aSubTask-eip activemq-component activemq6-component aggregate-eip +ai-observability ai-patterns ai-tool-component amqp-component 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 new file mode 100644 index 0000000000000..290129e311547 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc @@ -0,0 +1,37 @@ += AI Observability Component +:doctitle: AI Observability +:shortname: ai-observability +:artifactid: camel-ai-observability +:description: GenAI observability support for Camel AI components (OpenTelemetry and Micrometer) +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: +//Manually maintained attributes +:group: AI + +*Since Camel {since}* + +The `camel-ai-observability` module provides GenAI observability for Camel AI components following the +OpenTelemetry semantic conventions for GenAI (stable subset aligned with Spring AI). + +When `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath, langchain4j and OpenAI producers +emit child spans and metrics per LLM call with attributes such as `gen_ai.operation.name`, `gen_ai.system`, +`gen_ai.request.model`, `gen_ai.usage.input_tokens`, and `gen_ai.usage.output_tokens`. + +Global toggle: set `camel.ai.observability.enabled=false` to disable GenAI observability across all AI +components (default is `true` when a tracing or metrics backend is present). + +Phase 1 coverage: `langchain4j-chat`, `langchain4j-tools`, `langchain4j-agent`, `langchain4j-embeddings`, +and `openai`. + +New exchange headers for model identification on langchain4j components: + +* `CamelLangChain4jChatRequestModel` / `CamelLangChain4jChatResponseModel` +* `CamelLangChain4jToolsRequestModel` / `CamelLangChain4jToolsResponseModel` +* `CamelLangChain4jAgentRequestModel` / `CamelLangChain4jAgentResponseModel` +* `CamelLangChain4jEmbeddingsRequestModel` / `CamelLangChain4jEmbeddingsResponseModel` + +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`) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json index f4b422e1b40c6..bd1bdf642d53b 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json @@ -35,6 +35,7 @@ { "name": "camel.jbang.maven-settings", "required": false, "description": "Optional location of Maven settings.xml file to configure servers, repositories, mirrors, and proxies. If set to false, not even the default \/.m2\/settings.xml will be used.", "label": "maven", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.maven-settings-security", "required": false, "description": "Optional location of Maven settings-security.xml file to decrypt Maven Settings (settings.xml) file", "label": "maven", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.mavenWrapper", "required": false, "description": "Include Maven Wrapper files in the exported project", "type": "boolean", "javaType": "boolean", "defaultValue": true, "secret": false }, + { "name": "camel.jbang.mcp", "required": false, "description": "Embed dev\/diagnostics MCP tools on the local HTTP management server (\/mcp by default)", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "security": "insecure:dev" }, { "name": "camel.jbang.metrics", "required": false, "description": "Metrics (Micrometer and Prometheus) at \/observe\/metrics on local HTTP server (port 8080 by default) when running standalone Camel", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "deprecated": true }, { "name": "camel.jbang.openApi", "required": false, "description": "File name of open-api spec file (JSON or YAML) to generate routes from the swagger\/openapi API spec file.", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.openapiUi", "required": false, "description": "Swagger UI for REST OpenAPI at \/q\/openapi on local HTTP server (port 8080 by default)", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "security": "insecure:dev" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties index e1c80fb219286..0c7220c79f7a4 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others.properties @@ -1,3 +1,4 @@ +ai-observability attachments azure-schema-registry cli-connector diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/ai-observability.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/ai-observability.json new file mode 100644 index 0000000000000..81301a783591c --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/others/ai-observability.json @@ -0,0 +1,15 @@ +{ + "other": { + "kind": "other", + "name": "ai-observability", + "title": "AI Observability", + "description": "GenAI observability support for Camel AI components (OpenTelemetry and Micrometer)", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "ai", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-ai-observability", + "version": "4.22.0-SNAPSHOT" + } +} 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 new file mode 100644 index 0000000000000..e9f0e6465e637 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMetricsBackend.java @@ -0,0 +1,27 @@ +/* + * 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; + +/** + * Optional Micrometer metrics backend for GenAI observations. + */ +interface GenAiMetricsBackend { + + boolean isAvailable(); + + void recordMetrics(GenAiObservationContext context, GenAiUsage usage, Throwable error, long startNanos); +} 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 new file mode 100644 index 0000000000000..8b020194c07d0 --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiMicrometerSupport.java @@ -0,0 +1,97 @@ +/* + * 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.List; +import java.util.concurrent.TimeUnit; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; +import org.apache.camel.CamelContext; +import org.apache.camel.support.CamelContextHelper; +import org.apache.camel.util.ObjectHelper; + +/** + * Micrometer-backed metrics recording. Loaded reflectively only when {@link MeterRegistry} is on the classpath. + */ +final class GenAiMicrometerSupport implements GenAiMetricsBackend { + + private final MeterRegistry meterRegistry; + + GenAiMicrometerSupport(CamelContext camelContext) { + MeterRegistry registry = CamelContextHelper.findSingleByType(camelContext, MeterRegistry.class); + if (registry == null) { + registry = camelContext.getRegistry().lookupByNameAndType("metricsRegistry", MeterRegistry.class); + } + this.meterRegistry = registry; + } + + @Override + public boolean isAvailable() { + return meterRegistry != null; + } + + @Override + public void recordMetrics(GenAiObservationContext context, GenAiUsage usage, Throwable error, long startNanos) { + if (meterRegistry == null) { + return; + } + Iterable baseTags = baseTags(context, error); + Timer.builder(GenAiMetrics.CLIENT_OPERATION) + .tags(baseTags) + .register(meterRegistry) + .record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); + + if (usage != null) { + recordTokenCounter(usage.inputTokens(), GenAiMetrics.TOKEN_TYPE_INPUT, context, error); + recordTokenCounter(usage.outputTokens(), GenAiMetrics.TOKEN_TYPE_OUTPUT, context, error); + } + } + + private void recordTokenCounter( + Integer tokens, String tokenType, GenAiObservationContext context, Throwable error) { + if (tokens == null || tokens <= 0) { + return; + } + Counter.builder(GenAiMetrics.CLIENT_TOKEN_USAGE) + .tags(baseTags(context, error)) + .tag(GenAiMetrics.TAG_TOKEN_TYPE, tokenType) + .register(meterRegistry) + .increment(tokens); + } + + private static Iterable baseTags(GenAiObservationContext context, Throwable error) { + List tags = new ArrayList<>(); + tags.add(Tag.of(GenAiMetrics.TAG_OPERATION_NAME, context.operationName().value())); + tags.add(Tag.of(GenAiMetrics.TAG_SYSTEM, nullToUnknown(context.system()))); + tags.add(Tag.of(GenAiMetrics.TAG_REQUEST_MODEL, nullToUnknown(context.requestModel()))); + if (ObjectHelper.isNotEmpty(context.componentScheme())) { + tags.add(Tag.of(GenAiMetrics.TAG_CAMEL_COMPONENT, context.componentScheme())); + } + if (error != null) { + tags.add(Tag.of(GenAiMetrics.TAG_ERROR_TYPE, error.getClass().getSimpleName())); + } + return tags; + } + + private static String nullToUnknown(String value) { + return value == null || value.isBlank() ? "unknown" : value; + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java index 2efb07ea3264f..4455758ff3e74 100644 --- a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -16,18 +16,11 @@ */ package org.apache.camel.component.ai.observability; -import java.util.ArrayList; -import java.util.List; +import java.lang.reflect.Constructor; import java.util.Optional; -import java.util.concurrent.TimeUnit; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.Tag; -import io.micrometer.core.instrument.Timer; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; -import org.apache.camel.support.CamelContextHelper; import org.apache.camel.telemetry.Span; import org.apache.camel.telemetry.SpanLifecycleManager; import org.apache.camel.telemetry.SpanStorageManagerExchange; @@ -41,6 +34,9 @@ public final class GenAiObservability { private static final String CLIENT_SPAN_KIND = "CLIENT"; + 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 GenAiObservation NOOP = new NoopGenAiObservation(); private GenAiObservability() { @@ -70,19 +66,23 @@ public static GenAiObservation start(Exchange exchange, GenAiObservationContext return NOOP; } Tracer tracer = exchange.getContext().hasService(Tracer.class); - MeterRegistry meterRegistry = resolveMeterRegistry(exchange.getContext()); - if (tracer == null && meterRegistry == null) { + GenAiMetricsBackend metricsBackend = resolveMetricsBackend(exchange.getContext()); + if (tracer == null && (metricsBackend == null || !metricsBackend.isAvailable())) { return NOOP; } - return new DefaultGenAiObservation(exchange, context, tracer, meterRegistry); + return new DefaultGenAiObservation(exchange, context, tracer, metricsBackend); } - private static MeterRegistry resolveMeterRegistry(CamelContext camelContext) { - MeterRegistry registry = CamelContextHelper.findSingleByType(camelContext, MeterRegistry.class); - if (registry != null) { - return registry; + private static GenAiMetricsBackend resolveMetricsBackend(CamelContext camelContext) { + try { + Class.forName(METER_REGISTRY_CLASS); + Class supportClass = Class.forName(MICROMETER_SUPPORT_CLASS); + Constructor constructor = supportClass.getDeclaredConstructor(CamelContext.class); + constructor.setAccessible(true); + return (GenAiMetricsBackend) constructor.newInstance(camelContext); + } catch (ReflectiveOperationException | LinkageError e) { + return null; } - return camelContext.getRegistry().lookupByNameAndType("metricsRegistry", MeterRegistry.class); } private static final class DefaultGenAiObservation implements GenAiObservation { @@ -90,7 +90,7 @@ private static final class DefaultGenAiObservation implements GenAiObservation { private final Exchange exchange; private final GenAiObservationContext context; private final Tracer tracer; - private final MeterRegistry meterRegistry; + private final GenAiMetricsBackend metricsBackend; private final long startNanos; private Span span; private GenAiUsage usage; @@ -99,11 +99,11 @@ private static final class DefaultGenAiObservation implements GenAiObservation { private DefaultGenAiObservation( Exchange exchange, GenAiObservationContext context, Tracer tracer, - MeterRegistry meterRegistry) { + GenAiMetricsBackend metricsBackend) { this.exchange = exchange; this.context = context; this.tracer = tracer; - this.meterRegistry = meterRegistry; + this.metricsBackend = metricsBackend; this.startNanos = System.nanoTime(); startSpan(); } @@ -159,44 +159,10 @@ private void closeSpan() { } private void recordMetrics() { - if (meterRegistry == null) { + if (metricsBackend == null) { return; } - Iterable baseTags = baseTags(context, error); - Timer.builder(GenAiMetrics.CLIENT_OPERATION) - .tags(baseTags) - .register(meterRegistry) - .record(System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); - - if (usage != null) { - recordTokenCounter(usage.inputTokens(), GenAiMetrics.TOKEN_TYPE_INPUT); - recordTokenCounter(usage.outputTokens(), GenAiMetrics.TOKEN_TYPE_OUTPUT); - } - } - - private void recordTokenCounter(Integer tokens, String tokenType) { - if (tokens == null || tokens <= 0) { - return; - } - Counter.builder(GenAiMetrics.CLIENT_TOKEN_USAGE) - .tags(baseTags(context, error)) - .tag(GenAiMetrics.TAG_TOKEN_TYPE, tokenType) - .register(meterRegistry) - .increment(tokens); - } - - private static Iterable baseTags(GenAiObservationContext context, Throwable error) { - List tags = new ArrayList<>(); - tags.add(Tag.of(GenAiMetrics.TAG_OPERATION_NAME, context.operationName().value())); - tags.add(Tag.of(GenAiMetrics.TAG_SYSTEM, nullToUnknown(context.system()))); - tags.add(Tag.of(GenAiMetrics.TAG_REQUEST_MODEL, nullToUnknown(context.requestModel()))); - if (ObjectHelper.isNotEmpty(context.componentScheme())) { - tags.add(Tag.of(GenAiMetrics.TAG_CAMEL_COMPONENT, context.componentScheme())); - } - if (error != null) { - tags.add(Tag.of(GenAiMetrics.TAG_ERROR_TYPE, error.getClass().getSimpleName())); - } - return tags; + metricsBackend.recordMetrics(context, usage, error, startNanos); } private static void applyContextAttributes( diff --git a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java index 5abde16183937..989996a504ffa 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java +++ b/components/camel-ai/camel-langchain4j-agent/src/main/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentProducer.java @@ -163,7 +163,7 @@ public void process(Exchange exchange) throws Exception { result.tokenUsage() != null ? result.tokenUsage().inputTokenCount() : null, result.tokenUsage() != null ? result.tokenUsage().outputTokenCount() : null, result.finishReason(), - observationContext.requestModel())); + null)); } catch (RuntimeException e) { observation.recordError(e); throw e; @@ -187,7 +187,6 @@ private void populateResultHeaders(Result result, Exchange exchange, Str if (requestModel != null) { message.setHeader(Headers.REQUEST_MODEL, requestModel); - message.setHeader(Headers.RESPONSE_MODEL, requestModel); } if (result.finishReason() != null) { diff --git a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentResultHeadersTest.java b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentResultHeadersTest.java index ad359d87a0933..1f2e29c8fa2df 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentResultHeadersTest.java +++ b/components/camel-ai/camel-langchain4j-agent/src/test/java/org/apache/camel/component/langchain4j/agent/LangChain4jAgentResultHeadersTest.java @@ -113,6 +113,25 @@ void shouldPopulateAllResultHeaders() throws Exception { assertEquals("Sunny, 22C", toolExecutions.get(0).result()); } + @Test + void shouldNotSetResponseModelWhenUnavailableFromAgentResult() throws Exception { + agentRef.set((body, exchange) -> Result. builder() + .content("Plain answer") + .finishReason(FinishReason.STOP) + .tokenUsage(new TokenUsage(10, 5, 15)) + .build()); + + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + + template.sendBody("direct:start", "Hello"); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + Exchange exchange = mock.getExchanges().get(0); + assertNull(exchange.getMessage().getHeader(Headers.RESPONSE_MODEL)); + } + @Test void shouldNotSetSourcesOrToolExecutionsWhenEmpty() throws Exception { agentRef.set((body, exchange) -> Result. builder() diff --git a/components/camel-ai/camel-langchain4j-chat/pom.xml b/components/camel-ai/camel-langchain4j-chat/pom.xml index 8feb7a5cc1a45..c25f2d3c838ed 100644 --- a/components/camel-ai/camel-langchain4j-chat/pom.xml +++ b/components/camel-ai/camel-langchain4j-chat/pom.xml @@ -72,7 +72,12 @@ org.apache.camel - camel-test-spring-junit6 + camel-test-junit6 + test + + + org.apache.camel + camel-telemetry test diff --git a/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatObservabilityTest.java b/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatObservabilityTest.java new file mode 100644 index 0000000000000..43b6fae3bb13a --- /dev/null +++ b/components/camel-ai/camel-langchain4j-chat/src/test/java/org/apache/camel/component/langchain4j/chat/LangChain4jChatObservabilityTest.java @@ -0,0 +1,207 @@ +/* + * 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.langchain4j.chat; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.request.DefaultChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiAttributes; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Registry; +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.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangChain4jChatObservabilityTest extends CamelTestSupport { + + private RecordingTracer tracer; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + tracer = new RecordingTracer(); + CamelContextAware.trySetCamelContext(tracer, context); + tracer.init(context); + return context; + } + + @Override + protected void bindToRegistry(Registry registry) { + registry.bind("chatModel", new StubChatModel()); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start") + .to("langchain4j-chat:test?chatModel=#chatModel") + .to("mock:result"); + } + }; + } + + @Test + void shouldEmitGenAiSpanAndModelHeadersFromProducer() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + + template.sendBody("direct:start", "Hello"); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + assertThat(mock.getExchanges().get(0).getMessage().getBody(String.class)).isEqualTo("Hello back"); + assertThat(mock.getExchanges().get(0).getMessage().getHeader(LangChain4jChatHeaders.REQUEST_MODEL)) + .isEqualTo("gpt-4o"); + assertThat(mock.getExchanges().get(0).getMessage().getHeader(LangChain4jChatHeaders.RESPONSE_MODEL)) + .isEqualTo("gpt-4o-mini"); + + assertThat(tracer.genAiSpans()).hasSize(1); + Map tags = tracer.genAiSpans().get(0).tags(); + assertThat(tags.get(GenAiAttributes.OPERATION_NAME)).isEqualTo("chat"); + assertThat(tags.get(GenAiAttributes.REQUEST_MODEL)).isEqualTo("gpt-4o"); + assertThat(tags.get(GenAiAttributes.RESPONSE_MODEL)).isEqualTo("gpt-4o-mini"); + assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("langchain4j-chat"); + } + + private static final class StubChatModel implements ChatModel { + @Override + public ModelProvider provider() { + return ModelProvider.OPEN_AI; + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return DefaultChatRequestParameters.builder().modelName("gpt-4o").build(); + } + + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("Hello back")) + .modelName("gpt-4o-mini") + .tokenUsage(new TokenUsage(3, 2, 5)) + .finishReason(FinishReason.STOP) + .build(); + } + } + + private static final class RecordingTracer extends Tracer { + + private final List closedSpans = new ArrayList<>(); + + @Override + protected void initTracer() { + setSpanLifecycleManager(new RecordingSpanLifecycleManager()); + } + + List closedSpans() { + return closedSpans; + } + + List genAiSpans() { + return closedSpans.stream() + .filter(span -> span.tags().containsKey(GenAiAttributes.OPERATION_NAME)) + .toList(); + } + + private final class RecordingSpanLifecycleManager implements SpanLifecycleManager { + + @Override + public Span create(String spanName, String spanKind, Span parent, SpanContextPropagationExtractor extractor) { + return new RecordingSpan(spanName); + } + + @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 String name; + private final Map tags = new HashMap<>(); + + private RecordingSpan(String name) { + this.name = name; + } + + Map tags() { + return tags; + } + + @Override + public void log(Map 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/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java index da016be38b5e4..632b2f1fd7768 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java +++ b/components/camel-ai/camel-langchain4j-embeddings/src/main/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsProducer.java @@ -58,7 +58,7 @@ public void process(Exchange exchange) throws Exception { result.tokenUsage() != null ? result.tokenUsage().inputTokenCount() : null, result.tokenUsage() != null ? result.tokenUsage().outputTokenCount() : null, result.finishReason(), - observationContext.requestModel())); + null)); } catch (RuntimeException e) { observation.recordError(e); throw e; @@ -71,7 +71,6 @@ private static void populateHeaders( Message message, Response result, TextSegment textSegment, String requestModel) { if (requestModel != null) { message.setHeader(LangChain4jEmbeddingsHeaders.REQUEST_MODEL, requestModel); - message.setHeader(LangChain4jEmbeddingsHeaders.RESPONSE_MODEL, requestModel); } if (result.finishReason() != null) { diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java index 2bb0c6dfa5072..36d0893ae880c 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java @@ -556,6 +556,30 @@ public String langChain4jAgentOutputTokenCount() { public String langChain4jAgentTotalTokenCount() { return "CamelLangChain4jAgentTotalTokenCount"; } + /** + * The request model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code LangChain4jAgentRequestModel}. + */ + public String langChain4jAgentRequestModel() { + return "CamelLangChain4jAgentRequestModel"; + } + /** + * The response model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code LangChain4jAgentResponseModel}. + */ + public String langChain4jAgentResponseModel() { + return "CamelLangChain4jAgentResponseModel"; + } /** * RAG sources retrieved during agent invocation. * diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jChatEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jChatEndpointBuilderFactory.java index 6a3aa17f05dea..93f5689f2eb35 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jChatEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jChatEndpointBuilderFactory.java @@ -314,6 +314,30 @@ public String langChain4jChatOutputTokenCount() { public String langChain4jChatTotalTokenCount() { return "CamelLangChain4jChatTotalTokenCount"; } + /** + * The request model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code LangChain4jChatRequestModel}. + */ + public String langChain4jChatRequestModel() { + return "CamelLangChain4jChatRequestModel"; + } + /** + * The response model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code LangChain4jChatResponseModel}. + */ + public String langChain4jChatResponseModel() { + return "CamelLangChain4jChatResponseModel"; + } } static LangChain4jChatEndpointBuilder endpointBuilder(String componentName, String path) { class LangChain4jChatEndpointBuilderImpl extends AbstractEndpointBuilder implements LangChain4jChatEndpointBuilder, AdvancedLangChain4jChatEndpointBuilder { diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jEmbeddingsEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jEmbeddingsEndpointBuilderFactory.java index 018b11b436f7f..13151faabfded 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jEmbeddingsEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jEmbeddingsEndpointBuilderFactory.java @@ -255,6 +255,32 @@ public String langChain4jEmbeddingsOutputTokenCount() { public String langChain4jEmbeddingsTotalTokenCount() { return "CamelLangChain4jEmbeddingsTotalTokenCount"; } + /** + * The request model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code + * LangChain4jEmbeddingsRequestModel}. + */ + public String langChain4jEmbeddingsRequestModel() { + return "CamelLangChain4jEmbeddingsRequestModel"; + } + /** + * The response model name. + * + * The option is a: {@code String} type. + * + * Group: producer + * + * @return the name of the header {@code + * LangChain4jEmbeddingsResponseModel}. + */ + public String langChain4jEmbeddingsResponseModel() { + return "CamelLangChain4jEmbeddingsResponseModel"; + } /** * Embedding representation of a text. * diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jToolsEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jToolsEndpointBuilderFactory.java index eb3993ec1c401..54d4c80f5052b 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jToolsEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jToolsEndpointBuilderFactory.java @@ -704,6 +704,30 @@ public String langChain4jToolsOutputTokenCount() { public String langChain4jToolsTotalTokenCount() { return "CamelLangChain4jToolsTotalTokenCount"; } + /** + * The request model name. + * + * The option is a: {@code String} type. + * + * Group: common + * + * @return the name of the header {@code LangChain4jToolsRequestModel}. + */ + public String langChain4jToolsRequestModel() { + return "CamelLangChain4jToolsRequestModel"; + } + /** + * The response model name. + * + * The option is a: {@code String} type. + * + * Group: common + * + * @return the name of the header {@code LangChain4jToolsResponseModel}. + */ + public String langChain4jToolsResponseModel() { + return "CamelLangChain4jToolsResponseModel"; + } } static LangChain4jToolsEndpointBuilder endpointBuilder(String componentName, String path) { class LangChain4jToolsEndpointBuilderImpl extends AbstractEndpointBuilder implements LangChain4jToolsEndpointBuilder, AdvancedLangChain4jToolsEndpointBuilder { From 3fe1fb0eb68d122a84f8acde1eed5299e199bdc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 20:49:07 +0000 Subject: [PATCH 07/12] CAMEL-23861: Fix upgrade guide xref for unreleased ai-observability doc Use next@components:others: prefix so Antora resolves the new page from the pre-release components version during doc validation, matching how other new component docs are linked from the 4.22 upgrade guide. Co-authored-by: Omar Atie --- .../modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 24702775133c5..cb61044f56cec 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -277,7 +277,7 @@ Disable globally with `camel.ai.observability.enabled=false` (default is enabled LangChain4j components also expose request/response model names on new exchange headers (`CamelLangChain4j*RequestModel`, `CamelLangChain4j*ResponseModel`). See -xref:components:others:ai-observability.adoc[AI Observability] for metric names and span attributes. +xref:next@components:others:ai-observability.adoc[AI Observability] for metric names and span attributes. === camel-langchain4j-agent From e870186abbbcba2add14bd249ff95403c329492c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 21:37:24 +0000 Subject: [PATCH 08/12] CAMEL-23861: Commit regenerated BOM, catalog, and formatter output - Add camel-ai-observability to camel-bom (bom-generator) - Remove stale camel.jbang.mcp from catalog jbang metadata - Apply formatter alignment on AbstractAgent @since tag Co-authored-by: Omar Atie --- bom/camel-bom/pom.xml | 5 +++++ .../catalog/jbang/camel-jbang-configuration-metadata.json | 1 - .../camel/component/langchain4j/agent/api/AbstractAgent.java | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index ea5383026c763..38701033cad65 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -61,6 +61,11 @@ camel-activemq6 4.22.0-SNAPSHOT + + org.apache.camel + camel-ai-observability + 4.22.0-SNAPSHOT + org.apache.camel camel-ai-tool diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json index bd1bdf642d53b..f4b422e1b40c6 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/jbang/camel-jbang-configuration-metadata.json @@ -35,7 +35,6 @@ { "name": "camel.jbang.maven-settings", "required": false, "description": "Optional location of Maven settings.xml file to configure servers, repositories, mirrors, and proxies. If set to false, not even the default \/.m2\/settings.xml will be used.", "label": "maven", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.maven-settings-security", "required": false, "description": "Optional location of Maven settings-security.xml file to decrypt Maven Settings (settings.xml) file", "label": "maven", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.mavenWrapper", "required": false, "description": "Include Maven Wrapper files in the exported project", "type": "boolean", "javaType": "boolean", "defaultValue": true, "secret": false }, - { "name": "camel.jbang.mcp", "required": false, "description": "Embed dev\/diagnostics MCP tools on the local HTTP management server (\/mcp by default)", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "security": "insecure:dev" }, { "name": "camel.jbang.metrics", "required": false, "description": "Metrics (Micrometer and Prometheus) at \/observe\/metrics on local HTTP server (port 8080 by default) when running standalone Camel", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "deprecated": true }, { "name": "camel.jbang.openApi", "required": false, "description": "File name of open-api spec file (JSON or YAML) to generate routes from the swagger\/openapi API spec file.", "type": "string", "javaType": "String", "secret": false }, { "name": "camel.jbang.openapiUi", "required": false, "description": "Swagger UI for REST OpenAPI at \/q\/openapi on local HTTP server (port 8080 by default)", "type": "boolean", "javaType": "boolean", "defaultValue": false, "secret": false, "security": "insecure:dev" }, diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java index 19f277c512702..9d10345880bd4 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java @@ -70,7 +70,7 @@ protected AgentConfiguration getConfiguration() { * Returns the LangChain4j chat model configured for this agent. * * @return the chat model from agent configuration - * @since 4.22 + * @since 4.22 */ public ChatModel getChatModel() { return configuration.getChatModel(); From 38821e3ff8a793d109ae9cbc2e7a3e5f53b4da98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 22:20:54 +0000 Subject: [PATCH 09/12] CAMEL-23861: Split observability API and address latest review feedback Introduce camel-ai-observability-api with a reflection-based bridge so AI producers no longer pull camel-telemetry transitively. Move implementation into GenAiObservabilityImpl, restore camel-test-spring-junit6 alongside camel-test-junit6, fix others nav nesting, and add producer-level observability tests for tools, embeddings, and openai. Co-authored-by: Cursor --- bom/camel-bom/pom.xml | 5 + catalog/camel-allcomponents/pom.xml | 5 + .../camel-ai-observability-api/pom.xml | 54 +++++ .../org/apache/camel/other.properties | 7 + .../resources/ai-observability-api.json | 14 ++ .../ai/observability/GenAiAttributes.java | 0 .../ai/observability/GenAiModelResolver.java | 0 .../ai/observability/GenAiObservability.java | 90 ++++++++ .../GenAiObservabilityProperties.java | 0 .../ai/observability/GenAiObservation.java | 0 .../GenAiObservationContext.java | 0 .../ai/observability/GenAiOperationName.java | 0 .../ai/observability/GenAiUsage.java | 0 .../camel-ai/camel-ai-observability/pom.xml | 5 + .../src/main/docs/ai-observability.adoc | 5 +- ...ility.java => GenAiObservabilityImpl.java} | 31 +-- .../camel-ai/camel-langchain4j-agent/pom.xml | 2 +- .../camel-ai/camel-langchain4j-chat/pom.xml | 13 +- .../camel-langchain4j-embeddings/pom.xml | 13 +- ...angChain4jEmbeddingsObservabilityTest.java | 188 ++++++++++++++++ .../camel-ai/camel-langchain4j-tools/pom.xml | 13 +- .../LangChain4jToolsObservabilityTest.java | 204 ++++++++++++++++++ components/camel-ai/camel-openai/pom.xml | 13 +- .../openai/OpenAIObservabilityTest.java | 162 ++++++++++++++ components/camel-ai/pom.xml | 1 + .../examples/json/ai-observability-api.json | 1 + docs/components/modules/others/nav.adoc | 2 +- parent/pom.xml | 5 + 28 files changed, 799 insertions(+), 34 deletions(-) create mode 100644 components/camel-ai/camel-ai-observability-api/pom.xml create mode 100644 components/camel-ai/camel-ai-observability-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties create mode 100644 components/camel-ai/camel-ai-observability-api/src/generated/resources/ai-observability-api.json rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java (100%) rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java (100%) create mode 100644 components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java (100%) rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java (100%) rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java (100%) rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java (100%) rename components/camel-ai/{camel-ai-observability => camel-ai-observability-api}/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java (100%) rename components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/{GenAiObservability.java => GenAiObservabilityImpl.java} (89%) create mode 100644 components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsObservabilityTest.java create mode 100644 components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsObservabilityTest.java create mode 100644 components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIObservabilityTest.java create mode 120000 docs/components/modules/others/examples/json/ai-observability-api.json diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index 38701033cad65..e1da6d5371234 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -66,6 +66,11 @@ camel-ai-observability 4.22.0-SNAPSHOT + + org.apache.camel + camel-ai-observability-api + 4.22.0-SNAPSHOT + org.apache.camel camel-ai-tool diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index 1742b48b76881..f53d1a0fb9b7e 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -72,6 +72,11 @@ camel-ai-observability ${project.version} + + org.apache.camel + camel-ai-observability-api + ${project.version} + org.apache.camel camel-ai-tool diff --git a/components/camel-ai/camel-ai-observability-api/pom.xml b/components/camel-ai/camel-ai-observability-api/pom.xml new file mode 100644 index 0000000000000..af197e9d92f8a --- /dev/null +++ b/components/camel-ai/camel-ai-observability-api/pom.xml @@ -0,0 +1,54 @@ + + + + + 4.0.0 + + + camel-ai-parent + org.apache.camel + 4.22.0-SNAPSHOT + + + camel-ai-observability-api + jar + Camel :: AI :: Observability API + Lightweight GenAI observability API for Camel AI producers + + + 4.22.0 + Preview + + + + + org.apache.camel + camel-support + + + dev.langchain4j + langchain4j-core + ${langchain4j-version} + true + + + + diff --git a/components/camel-ai/camel-ai-observability-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties b/components/camel-ai/camel-ai-observability-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties new file mode 100644 index 0000000000000..05357c927bedc --- /dev/null +++ b/components/camel-ai/camel-ai-observability-api/src/generated/resources/META-INF/services/org/apache/camel/other.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +name=ai-observability-api +groupId=org.apache.camel +artifactId=camel-ai-observability-api +version=4.22.0-SNAPSHOT +projectName=Camel :: AI :: Observability API +projectDescription=Lightweight GenAI observability API for Camel AI producers diff --git a/components/camel-ai/camel-ai-observability-api/src/generated/resources/ai-observability-api.json b/components/camel-ai/camel-ai-observability-api/src/generated/resources/ai-observability-api.json new file mode 100644 index 0000000000000..2f94bcf4390be --- /dev/null +++ b/components/camel-ai/camel-ai-observability-api/src/generated/resources/ai-observability-api.json @@ -0,0 +1,14 @@ +{ + "other": { + "kind": "other", + "name": "ai-observability-api", + "title": "Ai Observability Api", + "description": "Lightweight GenAI observability API for Camel AI producers", + "deprecated": false, + "firstVersion": "4.22.0", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-ai-observability-api", + "version": "4.22.0-SNAPSHOT" + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiAttributes.java diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java new file mode 100644 index 0000000000000..ee469e1c55778 --- /dev/null +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -0,0 +1,90 @@ +/* + * 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.Optional; + +import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; + +/** + * Entry point for GenAI observability in Camel AI producers. + *

+ * The concrete tracing and metrics implementation lives in {@code camel-ai-observability} and is loaded via reflection + * when that module is on the classpath. Without it, calls return a no-op observation. + */ +public final class GenAiObservability { + + private static final String IMPL_CLASS = "org.apache.camel.component.ai.observability.GenAiObservabilityImpl"; + private static final GenAiObservation NOOP = new NoopGenAiObservation(); + + private GenAiObservability() { + } + + /** + * Whether GenAI observability is enabled for the given context. + */ + public static boolean isEnabled(CamelContext camelContext) { + if (camelContext == null) { + return false; + } + Optional property + = camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED); + if (property.isPresent()) { + return Boolean.parseBoolean(property.get().trim()); + } + return true; + } + + /** + * Starts a GenAI observation for a single LLM client call. Returns a no-op when disabled, when + * {@code camel-ai-observability} is absent, or when no backend is available. + */ + public static GenAiObservation start(Exchange exchange, GenAiObservationContext context) { + if (exchange == null || context == null || !isEnabled(exchange.getContext())) { + return NOOP; + } + CamelContext camelContext = exchange.getContext(); + if (camelContext.getClassResolver().resolveClass(IMPL_CLASS) == null) { + return NOOP; + } + try { + Class implClass = camelContext.getClassResolver().resolveClass(IMPL_CLASS); + return (GenAiObservation) implClass.getMethod("start", Exchange.class, GenAiObservationContext.class) + .invoke(null, exchange, context); + } catch (Throwable t) { + return NOOP; + } + } + + private static final class NoopGenAiObservation implements GenAiObservation { + @Override + public void recordSuccess(GenAiUsage usage) { + // noop + } + + @Override + public void recordError(Throwable error) { + // noop + } + + @Override + public void close() { + // noop + } + } +} diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityProperties.java diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservation.java diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservationContext.java diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiOperationName.java diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java similarity index 100% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java rename to components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiUsage.java diff --git a/components/camel-ai/camel-ai-observability/pom.xml b/components/camel-ai/camel-ai-observability/pom.xml index 7770b66860a29..107e2a2b39243 100644 --- a/components/camel-ai/camel-ai-observability/pom.xml +++ b/components/camel-ai/camel-ai-observability/pom.xml @@ -41,6 +41,11 @@ + + org.apache.camel + camel-ai-observability-api + ${project.version} + org.apache.camel camel-support 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 290129e311547..dec4990feb0b9 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 @@ -6,14 +6,15 @@ :since: 4.22 :supportlevel: Preview :tabs-sync-option: -//Manually maintained attributes -:group: AI *Since Camel {since}* The `camel-ai-observability` module provides GenAI observability for Camel AI components following the OpenTelemetry semantic conventions for GenAI (stable subset aligned with Spring AI). +AI producers depend on the lightweight `camel-ai-observability-api` module. Add `camel-ai-observability` to your +application classpath together with a tracing or metrics backend to enable span and metric emission. + When `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath, langchain4j and OpenAI producers emit child spans and metrics per LLM call with attributes such as `gen_ai.operation.name`, `gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, and `gen_ai.usage.output_tokens`. diff --git a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java similarity index 89% rename from components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java rename to components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java index 4455758ff3e74..f4d00e44734d8 100644 --- a/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java @@ -17,7 +17,6 @@ package org.apache.camel.component.ai.observability; import java.lang.reflect.Constructor; -import java.util.Optional; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; @@ -29,42 +28,23 @@ import org.apache.camel.util.ObjectHelper; /** - * Entry point for GenAI observability in Camel AI producers. + * GenAI observability implementation loaded reflectively from {@link GenAiObservability}. */ -public final class GenAiObservability { +public final class GenAiObservabilityImpl { + private static final GenAiObservation NOOP = new NoopGenAiObservation(); private static final String CLIENT_SPAN_KIND = "CLIENT"; 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 GenAiObservation NOOP = new NoopGenAiObservation(); - private GenAiObservability() { - } - - /** - * Whether GenAI observability is enabled for the given context. - */ - public static boolean isEnabled(CamelContext camelContext) { - if (camelContext == null) { - return false; - } - Optional property - = camelContext.getPropertiesComponent().resolveProperty(GenAiObservabilityProperties.ENABLED); - if (property.isPresent()) { - return Boolean.parseBoolean(property.get().trim()); - } - return true; + private GenAiObservabilityImpl() { } /** - * Starts a GenAI observation for a single LLM client call. Returns a no-op when disabled or no backend is - * available. + * 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) { - if (exchange == null || context == null || !isEnabled(exchange.getContext())) { - return NOOP; - } Tracer tracer = exchange.getContext().hasService(Tracer.class); GenAiMetricsBackend metricsBackend = resolveMetricsBackend(exchange.getContext()); if (tracer == null && (metricsBackend == null || !metricsBackend.isAvailable())) { @@ -78,7 +58,6 @@ private static GenAiMetricsBackend resolveMetricsBackend(CamelContext camelConte Class.forName(METER_REGISTRY_CLASS); Class supportClass = Class.forName(MICROMETER_SUPPORT_CLASS); Constructor constructor = supportClass.getDeclaredConstructor(CamelContext.class); - constructor.setAccessible(true); return (GenAiMetricsBackend) constructor.newInstance(camelContext); } catch (ReflectiveOperationException | LinkageError e) { return null; diff --git a/components/camel-ai/camel-langchain4j-agent/pom.xml b/components/camel-ai/camel-langchain4j-agent/pom.xml index 74f5da4faa9f2..310cfcdb235b1 100644 --- a/components/camel-ai/camel-langchain4j-agent/pom.xml +++ b/components/camel-ai/camel-langchain4j-agent/pom.xml @@ -72,7 +72,7 @@ org.apache.camel - camel-ai-observability + camel-ai-observability-api ${project.version} diff --git a/components/camel-ai/camel-langchain4j-chat/pom.xml b/components/camel-ai/camel-langchain4j-chat/pom.xml index c25f2d3c838ed..c1cea4ad8cf15 100644 --- a/components/camel-ai/camel-langchain4j-chat/pom.xml +++ b/components/camel-ai/camel-langchain4j-chat/pom.xml @@ -65,16 +65,27 @@ org.apache.camel - camel-ai-observability + camel-ai-observability-api ${project.version} + + org.apache.camel + camel-test-spring-junit6 + test + org.apache.camel camel-test-junit6 test + + org.apache.camel + camel-ai-observability + ${project.version} + test + org.apache.camel camel-telemetry diff --git a/components/camel-ai/camel-langchain4j-embeddings/pom.xml b/components/camel-ai/camel-langchain4j-embeddings/pom.xml index 8ea74e82ee59f..30e2e658bcbdd 100644 --- a/components/camel-ai/camel-langchain4j-embeddings/pom.xml +++ b/components/camel-ai/camel-langchain4j-embeddings/pom.xml @@ -68,11 +68,22 @@ org.apache.camel - camel-ai-observability + camel-ai-observability-api ${project.version} + + org.apache.camel + camel-ai-observability + ${project.version} + test + + + org.apache.camel + camel-telemetry + test + org.apache.camel camel-direct diff --git a/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsObservabilityTest.java b/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsObservabilityTest.java new file mode 100644 index 0000000000000..f4ed33d3574fd --- /dev/null +++ b/components/camel-ai/camel-langchain4j-embeddings/src/test/java/org/apache/camel/component/langchain4j/embeddings/LangChain4jEmbeddingsObservabilityTest.java @@ -0,0 +1,188 @@ +/* + * 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.langchain4j.embeddings; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.model.output.TokenUsage; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiAttributes; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Registry; +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.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangChain4jEmbeddingsObservabilityTest extends CamelTestSupport { + + private RecordingTracer tracer; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + tracer = new RecordingTracer(); + CamelContextAware.trySetCamelContext(tracer, context); + tracer.init(context); + return context; + } + + @Override + protected void bindToRegistry(Registry registry) { + registry.bind("embeddingModel", new StubEmbeddingModel()); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start") + .to("langchain4j-embeddings:test?embeddingModel=#embeddingModel") + .to("mock:result"); + } + }; + } + + @Test + void shouldEmitGenAiSpanFromEmbeddingsProducer() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + + template.sendBody("direct:start", TextSegment.from("Hello")); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + assertThat(tracer.genAiSpans()).hasSize(1); + Map tags = tracer.genAiSpans().get(0).tags(); + assertThat(tags.get(GenAiAttributes.OPERATION_NAME)).isEqualTo("embeddings"); + assertThat(tags.get(GenAiAttributes.REQUEST_MODEL)).isEqualTo("text-embedding-3-small"); + assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("langchain4j-embeddings"); + } + + private static final class StubEmbeddingModel implements EmbeddingModel { + @Override + public ModelProvider provider() { + return ModelProvider.OPEN_AI; + } + + @Override + public String modelName() { + return "text-embedding-3-small"; + } + + @Override + public Response embed(TextSegment textSegment) { + return Response.from(Embedding.from(new float[] { 0.1f, 0.2f }), + new TokenUsage(4, 0, 4)); + } + } + + private static final class RecordingTracer extends Tracer { + + private final List closedSpans = new ArrayList<>(); + + @Override + protected void initTracer() { + setSpanLifecycleManager(new RecordingSpanLifecycleManager()); + } + + List genAiSpans() { + return closedSpans.stream() + .filter(span -> span.tags().containsKey(GenAiAttributes.OPERATION_NAME)) + .toList(); + } + + private final class RecordingSpanLifecycleManager implements SpanLifecycleManager { + + @Override + public Span create(String spanName, String spanKind, Span parent, SpanContextPropagationExtractor extractor) { + return new RecordingSpan(spanName); + } + + @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 tags = new HashMap<>(); + + private RecordingSpan(String name) { + tags.put("name", name); + } + + Map tags() { + return tags; + } + + @Override + public void log(Map 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/components/camel-ai/camel-langchain4j-tools/pom.xml b/components/camel-ai/camel-langchain4j-tools/pom.xml index 28a8f97d8292e..f3b13a375e4e6 100644 --- a/components/camel-ai/camel-langchain4j-tools/pom.xml +++ b/components/camel-ai/camel-langchain4j-tools/pom.xml @@ -63,7 +63,7 @@ org.apache.camel - camel-ai-observability + camel-ai-observability-api ${project.version} @@ -73,6 +73,17 @@ camel-test-junit6 test + + org.apache.camel + camel-ai-observability + ${project.version} + test + + + org.apache.camel + camel-telemetry + test + org.junit.jupiter junit-jupiter diff --git a/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsObservabilityTest.java b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsObservabilityTest.java new file mode 100644 index 0000000000000..cbb03909844a8 --- /dev/null +++ b/components/camel-ai/camel-langchain4j-tools/src/test/java/org/apache/camel/component/langchain4j/tools/LangChain4jToolsObservabilityTest.java @@ -0,0 +1,204 @@ +/* + * 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.langchain4j.tools; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.ModelProvider; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.chat.request.DefaultChatRequestParameters; +import dev.langchain4j.model.chat.response.ChatResponse; +import dev.langchain4j.model.output.FinishReason; +import dev.langchain4j.model.output.TokenUsage; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiAttributes; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Registry; +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.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LangChain4jToolsObservabilityTest extends CamelTestSupport { + + private RecordingTracer tracer; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + tracer = new RecordingTracer(); + CamelContextAware.trySetCamelContext(tracer, context); + tracer.init(context); + + LangChain4jToolsComponent component + = context.getComponent(LangChain4jTools.SCHEME, LangChain4jToolsComponent.class); + component.getConfiguration().setChatModel(new StubChatModel()); + return context; + } + + @Override + protected void bindToRegistry(Registry registry) { + // no-op + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start") + .to("langchain4j-tools:invoke?tags=observability") + .to("mock:result"); + + from("langchain4j-tools:dummy?tags=observability&description=Dummy tool") + .setBody(constant("tool-result")); + } + }; + } + + @Test + void shouldEmitGenAiSpanFromToolsProducer() throws Exception { + MockEndpoint mock = getMockEndpoint("mock:result"); + mock.expectedMessageCount(1); + + template.sendBody("direct:start", List.of(UserMessage.from("Hello"))); + + mock.assertIsSatisfied(10, TimeUnit.SECONDS); + + assertThat(tracer.genAiSpans()).hasSize(1); + Map tags = tracer.genAiSpans().get(0).tags(); + assertThat(tags.get(GenAiAttributes.OPERATION_NAME)).isEqualTo("chat"); + assertThat(tags.get(GenAiAttributes.REQUEST_MODEL)).isEqualTo("gpt-4o"); + assertThat(tags.get(GenAiAttributes.RESPONSE_MODEL)).isEqualTo("gpt-4o-mini"); + assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("langchain4j-tools"); + } + + private static final class StubChatModel implements ChatModel { + @Override + public ModelProvider provider() { + return ModelProvider.OPEN_AI; + } + + @Override + public ChatRequestParameters defaultRequestParameters() { + return DefaultChatRequestParameters.builder().modelName("gpt-4o").build(); + } + + @Override + public ChatResponse doChat(ChatRequest request) { + return ChatResponse.builder() + .aiMessage(AiMessage.from("Hello back")) + .modelName("gpt-4o-mini") + .tokenUsage(new TokenUsage(3, 2, 5)) + .finishReason(FinishReason.STOP) + .build(); + } + } + + private static final class RecordingTracer extends Tracer { + + private final List closedSpans = new ArrayList<>(); + + @Override + protected void initTracer() { + setSpanLifecycleManager(new RecordingSpanLifecycleManager()); + } + + List genAiSpans() { + return closedSpans.stream() + .filter(span -> span.tags().containsKey(GenAiAttributes.OPERATION_NAME)) + .toList(); + } + + private final class RecordingSpanLifecycleManager implements SpanLifecycleManager { + + @Override + public Span create(String spanName, String spanKind, Span parent, SpanContextPropagationExtractor extractor) { + return new RecordingSpan(spanName); + } + + @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 tags = new HashMap<>(); + + private RecordingSpan(String name) { + tags.put("name", name); + } + + Map tags() { + return tags; + } + + @Override + public void log(Map 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/components/camel-ai/camel-openai/pom.xml b/components/camel-ai/camel-openai/pom.xml index 564dd753d6a5e..8b2b72997e87d 100644 --- a/components/camel-ai/camel-openai/pom.xml +++ b/components/camel-ai/camel-openai/pom.xml @@ -61,11 +61,22 @@ org.apache.camel - camel-ai-observability + camel-ai-observability-api ${project.version} + + org.apache.camel + camel-ai-observability + ${project.version} + test + + + org.apache.camel + camel-telemetry + test + org.apache.camel camel-test-junit6 diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIObservabilityTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIObservabilityTest.java new file mode 100644 index 0000000000000..f4ded79c55371 --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIObservabilityTest.java @@ -0,0 +1,162 @@ +/* + * 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.openai; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.Exchange; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ai.observability.GenAiAttributes; +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.infra.openai.mock.OpenAIMock; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +class OpenAIObservabilityTest extends CamelTestSupport { + + private RecordingTracer tracer; + + @RegisterExtension + static OpenAIMock openAIMock = new OpenAIMock().builder() + .when("hello") + .replyWith("Hi from mock") + .end() + .build(); + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + tracer = new RecordingTracer(); + CamelContextAware.trySetCamelContext(tracer, context); + tracer.init(context); + return context; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:chat") + .to("openai:chat-completion?model=gpt-4o&apiKey=dummy&baseUrl=" + openAIMock.getBaseUrl() + + "/v1"); + } + }; + } + + @Test + void shouldEmitGenAiSpanFromOpenAiProducer() { + Exchange result = template.request("direct:chat", e -> e.getIn().setBody("hello")); + + assertThat(result.getMessage().getBody(String.class)).isEqualTo("Hi from mock"); + assertThat(tracer.genAiSpans()).hasSize(1); + Map tags = tracer.genAiSpans().get(0).tags(); + assertThat(tags.get(GenAiAttributes.OPERATION_NAME)).isEqualTo("chat"); + assertThat(tags.get(GenAiAttributes.SYSTEM)).isEqualTo("openai"); + assertThat(tags.get(GenAiAttributes.REQUEST_MODEL)).isEqualTo("gpt-4o"); + assertThat(tags.get(GenAiAttributes.CAMEL_COMPONENT)).isEqualTo("openai"); + } + + private static final class RecordingTracer extends Tracer { + + private final List closedSpans = new ArrayList<>(); + + @Override + protected void initTracer() { + setSpanLifecycleManager(new RecordingSpanLifecycleManager()); + } + + List genAiSpans() { + return closedSpans.stream() + .filter(span -> span.tags().containsKey(GenAiAttributes.OPERATION_NAME)) + .toList(); + } + + private final class RecordingSpanLifecycleManager implements SpanLifecycleManager { + + @Override + public Span create(String spanName, String spanKind, Span parent, SpanContextPropagationExtractor extractor) { + return new RecordingSpan(spanName); + } + + @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 tags = new HashMap<>(); + + private RecordingSpan(String name) { + tags.put("name", name); + } + + Map tags() { + return tags; + } + + @Override + public void log(Map 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/components/camel-ai/pom.xml b/components/camel-ai/pom.xml index b4c738ea6f485..b83cf19064433 100644 --- a/components/camel-ai/pom.xml +++ b/components/camel-ai/pom.xml @@ -35,6 +35,7 @@ camel-a2a + camel-ai-observability-api camel-ai-observability camel-ai-tool camel-chatscript diff --git a/docs/components/modules/others/examples/json/ai-observability-api.json b/docs/components/modules/others/examples/json/ai-observability-api.json new file mode 120000 index 0000000000000..a0831da1cbc03 --- /dev/null +++ b/docs/components/modules/others/examples/json/ai-observability-api.json @@ -0,0 +1 @@ +../../../../../../components/camel-ai/camel-ai-observability-api/src/generated/resources/ai-observability-api.json \ No newline at end of file diff --git a/docs/components/modules/others/nav.adoc b/docs/components/modules/others/nav.adoc index 4cb2b9e946252..35491a98fa231 100644 --- a/docs/components/modules/others/nav.adoc +++ b/docs/components/modules/others/nav.adoc @@ -4,7 +4,7 @@ * xref:others:index.adoc[Miscellaneous Components] ** xref:a2a-consumer.adoc[A2A - Consumer Guide] ** xref:a2a-producer.adoc[A2A - Producer Guide] -*** xref:ai-observability.adoc[AI Observability] +** xref:ai-observability.adoc[AI Observability] ** xref:attachments.adoc[Attachments] ** xref:aws-bedrock-examples.adoc[AWS Bedrock - Examples] ** xref:aws-bedrock-models.adoc[AWS Bedrock - Supported Models] diff --git a/parent/pom.xml b/parent/pom.xml index cca05268746d8..7924b908bf044 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -722,6 +722,11 @@ camel-ai-observability ${project.version} + + org.apache.camel + camel-ai-observability-api + ${project.version} + org.apache.camel camel-ai-tool From 5f196cf0e168995045cf4f2a27d1ad974c97bae8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 05:33:04 +0000 Subject: [PATCH 10/12] CAMEL-23861: Address round-3 PR review feedback - Avoid double resolveClass and narrow catch in GenAiObservability.start() - Fix error-path RESPONSE_MODEL span tag overwrite in GenAiObservabilityImpl - Gate OpenAI streaming includeUsage behind observability enabled flag - Clarify agent RESPONSE_MODEL header metadata and upgrade guide - Regenerate catalog ai-observability doc; keep jbang metadata unchanged Co-authored-by: Cursor Agent --- .../camel/catalog/components/langchain4j-agent.json | 2 +- .../org/apache/camel/catalog/docs/ai-observability.adoc | 5 +++-- .../component/ai/observability/GenAiObservability.java | 6 +++--- .../ai/observability/GenAiObservabilityImpl.java | 3 --- .../camel/component/langchain4j/agent/api/Headers.java | 4 +++- .../component/langchain4j/agent/langchain4j-agent.json | 2 +- .../apache/camel/component/openai/OpenAIProducer.java | 8 +++++--- .../modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 9 +++++++-- 8 files changed, 23 insertions(+), 16 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json index e32df68f75a03..b574b252dbbd9 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-agent.json @@ -48,7 +48,7 @@ "CamelLangChain4jAgentOutputTokenCount": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentTotalTokenCount": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOTAL_TOKEN_COUNT" }, "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, - "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, + "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } }, 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 290129e311547..dec4990feb0b9 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 @@ -6,14 +6,15 @@ :since: 4.22 :supportlevel: Preview :tabs-sync-option: -//Manually maintained attributes -:group: AI *Since Camel {since}* The `camel-ai-observability` module provides GenAI observability for Camel AI components following the OpenTelemetry semantic conventions for GenAI (stable subset aligned with Spring AI). +AI producers depend on the lightweight `camel-ai-observability-api` module. Add `camel-ai-observability` to your +application classpath together with a tracing or metrics backend to enable span and metric emission. + When `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath, langchain4j and OpenAI producers emit child spans and metrics per LLM call with attributes such as `gen_ai.operation.name`, `gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, and `gen_ai.usage.output_tokens`. diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java index ee469e1c55778..51863192c637d 100644 --- a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -59,14 +59,14 @@ public static GenAiObservation start(Exchange exchange, GenAiObservationContext return NOOP; } CamelContext camelContext = exchange.getContext(); - if (camelContext.getClassResolver().resolveClass(IMPL_CLASS) == null) { + Class implClass = camelContext.getClassResolver().resolveClass(IMPL_CLASS); + if (implClass == null) { return NOOP; } try { - Class implClass = camelContext.getClassResolver().resolveClass(IMPL_CLASS); return (GenAiObservation) implClass.getMethod("start", Exchange.class, GenAiObservationContext.class) .invoke(null, exchange, context); - } catch (Throwable t) { + } catch (ReflectiveOperationException | LinkageError e) { return NOOP; } } 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 f4d00e44734d8..fec4cbedb35cb 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 @@ -165,9 +165,6 @@ private static void applyContextAttributes( if (usage.finishReason() != null) { span.setTag(GenAiAttributes.FINISH_REASONS, usage.finishReason()); } - if (usage.responseModel() != null && !usage.responseModel().isBlank()) { - span.setTag(GenAiAttributes.RESPONSE_MODEL, usage.responseModel()); - } } } diff --git a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java index 49e289f28c4a9..171a9dde6f95a 100644 --- a/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java +++ b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/Headers.java @@ -59,7 +59,9 @@ public class Headers { @Metadata(description = "The request model name.", javaType = "String") public static final String REQUEST_MODEL = "CamelLangChain4jAgentRequestModel"; - @Metadata(description = "The response model name.", javaType = "String") + @Metadata(description = "The response model name. Not set by the agent producer when langchain4j Result " + + "does not expose it.", + javaType = "String") public static final String RESPONSE_MODEL = "CamelLangChain4jAgentResponseModel"; @Metadata(description = "RAG sources retrieved during agent invocation.", diff --git a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json index e32df68f75a03..b574b252dbbd9 100644 --- a/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json +++ b/components/camel-ai/camel-langchain4j-agent/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/agent/langchain4j-agent.json @@ -48,7 +48,7 @@ "CamelLangChain4jAgentOutputTokenCount": { "index": 8, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Output Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#OUTPUT_TOKEN_COUNT" }, "CamelLangChain4jAgentTotalTokenCount": { "index": 9, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The Total Token Count.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOTAL_TOKEN_COUNT" }, "CamelLangChain4jAgentRequestModel": { "index": 10, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The request model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#REQUEST_MODEL" }, - "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, + "CamelLangChain4jAgentResponseModel": { "index": 11, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The response model name. Not set by the agent producer when langchain4j Result does not expose it.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#RESPONSE_MODEL" }, "CamelLangChain4jAgentSources": { "index": 12, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "RAG sources retrieved during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#SOURCES" }, "CamelLangChain4jAgentToolExecutions": { "index": 13, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Tool executions performed during agent invocation.", "constantName": "org.apache.camel.component.langchain4j.agent.api.Headers#TOOL_EXECUTIONS" } }, diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index 9270c3b7a740e..c97c0f0167492 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -610,9 +610,11 @@ private void enforceAgenticTokenBudget( private void processStreaming(Exchange exchange, ChatCompletionCreateParams params) { String requestModel = params.model().toString(); - ChatCompletionCreateParams streamingParams = params.toBuilder() - .streamOptions(ChatCompletionStreamOptions.builder().includeUsage(true).build()) - .build(); + ChatCompletionCreateParams.Builder streamingBuilder = params.toBuilder(); + if (GenAiObservability.isEnabled(exchange.getContext())) { + streamingBuilder.streamOptions(ChatCompletionStreamOptions.builder().includeUsage(true).build()); + } + ChatCompletionCreateParams streamingParams = streamingBuilder.build(); GenAiObservationContext observationContext = GenAiObservationContext.builder() .operationName(GenAiOperationName.CHAT) .system("openai") diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index cb61044f56cec..bb0d07291d0ed 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -275,8 +275,13 @@ LangChain4j and OpenAI producers now emit GenAI observability data (OpenTelemetr Micrometer metrics) when `camel-opentelemetry2` and/or `camel-micrometer` is on the classpath. Disable globally with `camel.ai.observability.enabled=false` (default is enabled). -LangChain4j components also expose request/response model names on new exchange headers -(`CamelLangChain4j*RequestModel`, `CamelLangChain4j*ResponseModel`). See +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. + +LangChain4j components also expose request model names on new exchange headers +(`CamelLangChain4j*RequestModel`). The response model header (`CamelLangChain4j*ResponseModel`) is set when +the underlying client exposes it (for example langchain4j-chat); the agent and embeddings producers omit it when +unavailable. See xref:next@components:others:ai-observability.adoc[AI Observability] for metric names and span attributes. === camel-langchain4j-agent From 4450d6bc6e2ad63b50499f3c8b7728e3c0ac3660 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 06:21:35 +0000 Subject: [PATCH 11/12] CAMEL-23861: Regenerate endpoint DSL for RESPONSE_MODEL header metadata Sync LangChain4jAgentEndpointBuilderFactory with updated @Metadata description on Headers.RESPONSE_MODEL from review feedback. Co-authored-by: Cursor Agent --- .../endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java index 36d0893ae880c..64e161328b753 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/LangChain4jAgentEndpointBuilderFactory.java @@ -569,7 +569,8 @@ public String langChain4jAgentRequestModel() { return "CamelLangChain4jAgentRequestModel"; } /** - * The response model name. + * The response model name. Not set by the agent producer when + * langchain4j Result does not expose it. * * The option is a: {@code String} type. * From 8e424d403b86a274f39e8c179d6221d33b94543c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 15:19:39 +0000 Subject: [PATCH 12/12] CAMEL-23861: Address round-4 PR review feedback - Cache GenAI observability reflection bridge per CamelContext - Cache Micrometer metrics backend per CamelContext - Add DEBUG logging when reflective bridge/metrics setup fails - Map dev.langchain4j.model.google package to "google" system name - Use Math.toIntExact for OpenAI token counts passed to GenAiUsage Co-authored-by: Cursor Agent --- .../ai/observability/GenAiModelResolver.java | 6 ++- .../ai/observability/GenAiObservability.java | 42 +++++++++++++++++-- .../observability/GenAiObservabilityImpl.java | 32 ++++++++++++-- .../model/google/FakeGoogleModel.java | 20 +++++++++ .../model/vertexai/FakeVertexAiModel.java | 20 +++++++++ .../observability/GenAiModelResolverTest.java | 12 ++++++ .../component/openai/OpenAIProducer.java | 12 ++++-- 7 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/google/FakeGoogleModel.java create mode 100644 components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/vertexai/FakeVertexAiModel.java diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java index 6d09a1e5ace1d..335e1c259c3c5 100644 --- a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java @@ -107,10 +107,12 @@ private static String resolveSystemFromPackage(String packageName) { if (packageName.startsWith("dev.langchain4j.model.azure")) { return "azure.ai.openai"; } - if (packageName.startsWith("dev.langchain4j.model.vertexai") - || packageName.startsWith("dev.langchain4j.model.google")) { + if (packageName.startsWith("dev.langchain4j.model.vertexai")) { return "gcp.vertex_ai"; } + if (packageName.startsWith("dev.langchain4j.model.google")) { + return "google"; + } if (packageName.startsWith("dev.langchain4j.model.mistralai")) { return "mistral_ai"; } diff --git a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java index 51863192c637d..a6c5becb80031 100644 --- a/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java +++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java @@ -16,10 +16,15 @@ */ package org.apache.camel.component.ai.observability; +import java.lang.reflect.Method; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Entry point for GenAI observability in Camel AI producers. @@ -29,8 +34,11 @@ */ public final class GenAiObservability { + private static final Logger LOG = LoggerFactory.getLogger(GenAiObservability.class); private static final String IMPL_CLASS = "org.apache.camel.component.ai.observability.GenAiObservabilityImpl"; private static final GenAiObservation NOOP = new NoopGenAiObservation(); + private static final ImplBridge UNAVAILABLE_BRIDGE = new ImplBridge(null); + private static final ConcurrentMap BRIDGES = new ConcurrentHashMap<>(); private GenAiObservability() { } @@ -59,18 +67,44 @@ public static GenAiObservation start(Exchange exchange, GenAiObservationContext return NOOP; } CamelContext camelContext = exchange.getContext(); - Class implClass = camelContext.getClassResolver().resolveClass(IMPL_CLASS); - if (implClass == null) { + ImplBridge bridge = BRIDGES.computeIfAbsent(camelContext, GenAiObservability::resolveBridge); + if (bridge.startMethod == null) { return NOOP; } try { - return (GenAiObservation) implClass.getMethod("start", Exchange.class, GenAiObservationContext.class) - .invoke(null, exchange, context); + return (GenAiObservation) bridge.startMethod.invoke(null, exchange, context); } catch (ReflectiveOperationException | LinkageError e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Unable to start GenAI observation via {}", IMPL_CLASS, e); + } return NOOP; } } + private static ImplBridge resolveBridge(CamelContext camelContext) { + Class implClass = camelContext.getClassResolver().resolveClass(IMPL_CLASS); + if (implClass == null) { + return UNAVAILABLE_BRIDGE; + } + try { + Method startMethod = implClass.getMethod("start", Exchange.class, GenAiObservationContext.class); + return new ImplBridge(startMethod); + } catch (ReflectiveOperationException | LinkageError e) { + if (LOG.isDebugEnabled()) { + LOG.debug("Unable to resolve GenAI observability bridge from {}", IMPL_CLASS, e); + } + return UNAVAILABLE_BRIDGE; + } + } + + private static final class ImplBridge { + private final Method startMethod; + + private ImplBridge(Method startMethod) { + this.startMethod = startMethod; + } + } + private static final class NoopGenAiObservation implements GenAiObservation { @Override public void recordSuccess(GenAiUsage usage) { 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 fec4cbedb35cb..62021eb337799 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 @@ -17,6 +17,8 @@ package org.apache.camel.component.ai.observability; import java.lang.reflect.Constructor; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import org.apache.camel.CamelContext; import org.apache.camel.Exchange; @@ -26,17 +28,22 @@ import org.apache.camel.telemetry.Tracer; import org.apache.camel.telemetry.propagation.CamelHeadersSpanContextPropagationExtractor; import org.apache.camel.util.ObjectHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * GenAI observability implementation loaded reflectively from {@link GenAiObservability}. */ public final class GenAiObservabilityImpl { + private static final Logger LOG = LoggerFactory.getLogger(GenAiObservabilityImpl.class); private static final GenAiObservation NOOP = new NoopGenAiObservation(); private static final String CLIENT_SPAN_KIND = "CLIENT"; 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 GenAiMetricsBackend NO_METRICS_BACKEND = new NoMetricsBackend(); + private static final ConcurrentMap METRICS_BACKENDS = new ConcurrentHashMap<>(); private GenAiObservabilityImpl() { } @@ -47,20 +54,27 @@ private GenAiObservabilityImpl() { public static GenAiObservation start(Exchange exchange, GenAiObservationContext context) { Tracer tracer = exchange.getContext().hasService(Tracer.class); GenAiMetricsBackend metricsBackend = resolveMetricsBackend(exchange.getContext()); - if (tracer == null && (metricsBackend == null || !metricsBackend.isAvailable())) { + if (tracer == null && !metricsBackend.isAvailable()) { return NOOP; } return new DefaultGenAiObservation(exchange, context, tracer, metricsBackend); } private static GenAiMetricsBackend resolveMetricsBackend(CamelContext camelContext) { + return METRICS_BACKENDS.computeIfAbsent(camelContext, GenAiObservabilityImpl::createMetricsBackend); + } + + private static GenAiMetricsBackend createMetricsBackend(CamelContext camelContext) { try { Class.forName(METER_REGISTRY_CLASS); Class supportClass = Class.forName(MICROMETER_SUPPORT_CLASS); Constructor constructor = supportClass.getDeclaredConstructor(CamelContext.class); return (GenAiMetricsBackend) constructor.newInstance(camelContext); } catch (ReflectiveOperationException | LinkageError e) { - return null; + if (LOG.isDebugEnabled()) { + LOG.debug("Micrometer metrics backend unavailable for GenAI observability", e); + } + return NO_METRICS_BACKEND; } } @@ -138,7 +152,7 @@ private void closeSpan() { } private void recordMetrics() { - if (metricsBackend == null) { + if (!metricsBackend.isAvailable()) { return; } metricsBackend.recordMetrics(context, usage, error, startNanos); @@ -189,4 +203,16 @@ public void close() { // noop } } + + private static final class NoMetricsBackend implements GenAiMetricsBackend { + @Override + public boolean isAvailable() { + return false; + } + + @Override + public void recordMetrics(GenAiObservationContext context, GenAiUsage usage, Throwable error, long startNanos) { + // noop + } + } } diff --git a/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/google/FakeGoogleModel.java b/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/google/FakeGoogleModel.java new file mode 100644 index 0000000000000..6cce1ca59535b --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/google/FakeGoogleModel.java @@ -0,0 +1,20 @@ +/* + * 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 dev.langchain4j.model.google; + +public class FakeGoogleModel { +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/vertexai/FakeVertexAiModel.java b/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/vertexai/FakeVertexAiModel.java new file mode 100644 index 0000000000000..efaf5d354ed3d --- /dev/null +++ b/components/camel-ai/camel-ai-observability/src/test/java/dev/langchain4j/model/vertexai/FakeVertexAiModel.java @@ -0,0 +1,20 @@ +/* + * 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 dev.langchain4j.model.vertexai; + +public class FakeVertexAiModel { +} diff --git a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java index 43e42806e844f..7e031883d8f6c 100644 --- a/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java +++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java @@ -64,6 +64,18 @@ void shouldReturnUnknownForNullModel() { assertThat(GenAiModelResolver.resolveModelName(null)).isEqualTo("unknown"); } + @Test + void shouldResolveGoogleProviderFromPackageName() { + assertThat(GenAiModelResolver.resolveSystem(new dev.langchain4j.model.google.FakeGoogleModel())) + .isEqualTo("google"); + } + + @Test + void shouldResolveVertexAiProviderFromPackageName() { + assertThat(GenAiModelResolver.resolveSystem(new dev.langchain4j.model.vertexai.FakeVertexAiModel())) + .isEqualTo("gcp.vertex_ai"); + } + static class FakeOpenAiChatModel implements ChatModel { @Override public ModelProvider provider() { diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java index c97c0f0167492..2867992765fec 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java @@ -657,8 +657,8 @@ public void onComplete(Exchange e) { CompletionUsage usage = usageRef.get(); String responseModel = responseModelRef.get() != null ? responseModelRef.get() : requestModel; observation.recordSuccess(GenAiUsage.of( - usage != null ? (int) usage.promptTokens() : null, - usage != null ? (int) usage.completionTokens() : null, + usage != null ? toTokenCount(usage.promptTokens()) : null, + usage != null ? toTokenCount(usage.completionTokens()) : null, null, responseModel)); observation.close(); @@ -707,6 +707,10 @@ private static String getFinishReasonString(ChatCompletion.Choice choice) { .orElse("stop"); } + private static Integer toTokenCount(long tokens) { + return Math.toIntExact(tokens); + } + private ChatCompletion createChatCompletion(Exchange exchange, ChatCompletionCreateParams params) { String requestModel = params.model().toString(); GenAiObservationContext observationContext = GenAiObservationContext.builder() @@ -720,8 +724,8 @@ private ChatCompletion createChatCompletion(Exchange exchange, ChatCompletionCre ChatCompletion response = getEndpoint().getClient().chat().completions().create(params); CompletionUsage usage = response.usage().orElse(null); observation.recordSuccess(GenAiUsage.of( - usage != null ? (int) usage.promptTokens() : null, - usage != null ? (int) usage.completionTokens() : null, + usage != null ? toTokenCount(usage.promptTokens()) : null, + usage != null ? toTokenCount(usage.completionTokens()) : null, response.choices().isEmpty() ? null : getFinishReasonString(response.choices().get(0)), response.model())); return response;