diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml
index ea5383026c763..e1da6d5371234 100644
--- a/bom/camel-bom/pom.xml
+++ b/bom/camel-bom/pom.xml
@@ -61,6 +61,16 @@
camel-activemq6
4.22.0-SNAPSHOT
+
+ org.apache.camel
+ 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 9ff95f7750ff9..f53d1a0fb9b7e 100644
--- a/catalog/camel-allcomponents/pom.xml
+++ b/catalog/camel-allcomponents/pom.xml
@@ -67,6 +67,16 @@
camel-activemq6
${project.version}
+
+ org.apache.camel
+ camel-ai-observability
+ ${project.version}
+
+
+ org.apache.camel
+ camel-ai-observability-api
+ ${project.version}
+
org.apache.camel
camel-ai-tool
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..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
@@ -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. 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" }
},
"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..dec4990feb0b9
--- /dev/null
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
@@ -0,0 +1,38 @@
+= 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:
+
+*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`.
+
+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/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-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-api/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
new file mode 100644
index 0000000000000..2357ce889fcd1
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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-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
new file mode 100644
index 0000000000000..335e1c259c3c5
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiModelResolver.java
@@ -0,0 +1,127 @@
+/*
+ * 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 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.
+ */
+public final class GenAiModelResolver {
+
+ private static final String UNKNOWN = "unknown";
+
+ private GenAiModelResolver() {
+ }
+
+ public static String resolveSystem(Object model) {
+ if (model == null) {
+ return UNKNOWN;
+ }
+ 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 (packageName.startsWith("dev.langchain4j.model.anthropic")) {
+ return "anthropic";
+ }
+ if (packageName.startsWith("dev.langchain4j.model.ollama")) {
+ return "ollama";
+ }
+ if (packageName.startsWith("dev.langchain4j.model.azure")) {
+ return "azure.ai.openai";
+ }
+ 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";
+ }
+ if (packageName.startsWith("dev.langchain4j.model.huggingface")) {
+ return "huggingface";
+ }
+ if (packageName.startsWith("dev.langchain4j.model.bedrock")) {
+ return "aws.bedrock";
+ }
+ return UNKNOWN;
+ }
+}
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..a6c5becb80031
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/src/main/java/org/apache/camel/component/ai/observability/GenAiObservability.java
@@ -0,0 +1,124 @@
+/*
+ * 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;
+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.
+ *
+ * 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 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() {
+ }
+
+ /**
+ * 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();
+ ImplBridge bridge = BRIDGES.computeIfAbsent(camelContext, GenAiObservability::resolveBridge);
+ if (bridge.startMethod == null) {
+ return NOOP;
+ }
+ try {
+ 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) {
+ // noop
+ }
+
+ @Override
+ public void recordError(Throwable error) {
+ // noop
+ }
+
+ @Override
+ public void close() {
+ // noop
+ }
+ }
+}
diff --git a/components/camel-ai/camel-ai-observability-api/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
new file mode 100644
index 0000000000000..3f6634808220e
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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-api/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
new file mode 100644
index 0000000000000..57aff648cefec
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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-api/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
new file mode 100644
index 0000000000000..015a23337cfd0
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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-api/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
new file mode 100644
index 0000000000000..b6334b6c510c4
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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-api/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
new file mode 100644
index 0000000000000..a21f66397bd8b
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability-api/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/pom.xml b/components/camel-ai/camel-ai-observability/pom.xml
new file mode 100644
index 0000000000000..107e2a2b39243
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability/pom.xml
@@ -0,0 +1,81 @@
+
+
+
+
+ 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-ai-observability-api
+ ${project.version}
+
+
+ org.apache.camel
+ camel-support
+
+
+ org.apache.camel
+ camel-telemetry
+
+
+ io.micrometer
+ micrometer-core
+ true
+
+
+ dev.langchain4j
+ langchain4j-core
+ ${langchain4j-version}
+ true
+
+
+
+ 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..dec4990feb0b9
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
@@ -0,0 +1,38 @@
+= 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:
+
+*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`.
+
+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/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/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/GenAiObservabilityImpl.java b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
new file mode 100644
index 0000000000000..62021eb337799
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability/src/main/java/org/apache/camel/component/ai/observability/GenAiObservabilityImpl.java
@@ -0,0 +1,218 @@
+/*
+ * 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.Constructor;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+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.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() {
+ }
+
+ /**
+ * Starts a GenAI observation for a single LLM client call. Returns a no-op when no backend is available.
+ */
+ public static GenAiObservation start(Exchange exchange, GenAiObservationContext context) {
+ Tracer tracer = exchange.getContext().hasService(Tracer.class);
+ GenAiMetricsBackend metricsBackend = resolveMetricsBackend(exchange.getContext());
+ if (tracer == null && !metricsBackend.isAvailable()) {
+ 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) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Micrometer metrics backend unavailable for GenAI observability", e);
+ }
+ return NO_METRICS_BACKEND;
+ }
+ }
+
+ private static final class DefaultGenAiObservation implements GenAiObservation {
+
+ private final Exchange exchange;
+ private final GenAiObservationContext context;
+ private final Tracer tracer;
+ private final GenAiMetricsBackend metricsBackend;
+ private final long startNanos;
+ private Span span;
+ private GenAiUsage usage;
+ private Throwable error;
+ private boolean closed;
+
+ private DefaultGenAiObservation(
+ Exchange exchange, GenAiObservationContext context, Tracer tracer,
+ GenAiMetricsBackend metricsBackend) {
+ this.exchange = exchange;
+ this.context = context;
+ this.tracer = tracer;
+ this.metricsBackend = metricsBackend;
+ 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();
+ 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);
+ }
+
+ @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 (!metricsBackend.isAvailable()) {
+ return;
+ }
+ metricsBackend.recordMetrics(context, usage, error, startNanos);
+ }
+
+ 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());
+ }
+ }
+ }
+
+ 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
+ }
+ }
+
+ 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
new file mode 100644
index 0000000000000..7e031883d8f6c
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiModelResolverTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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 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;
+
+class GenAiModelResolverTest {
+
+ @Test
+ void shouldResolveOpenAiProviderFromChatModel() {
+ assertThat(GenAiModelResolver.resolveSystem(new FakeOpenAiChatModel())).isEqualTo("openai");
+ }
+
+ @Test
+ void shouldResolveOllamaProviderFromChatModel() {
+ assertThat(GenAiModelResolver.resolveSystem(new FakeOllamaChatModel())).isEqualTo("ollama");
+ }
+
+ @Test
+ 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
+ void shouldReturnUnknownForNullModel() {
+ assertThat(GenAiModelResolver.resolveSystem(null)).isEqualTo("unknown");
+ 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() {
+ return ModelProvider.OPEN_AI;
+ }
+
+ @Override
+ public ChatRequestParameters defaultRequestParameters() {
+ return DefaultChatRequestParameters.builder().modelName("gpt-4o").build();
+ }
+ }
+
+ 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-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
new file mode 100644
index 0000000000000..f58cb29dc2ca2
--- /dev/null
+++ b/components/camel-ai/camel-ai-observability/src/test/java/org/apache/camel/component/ai/observability/GenAiObservabilityTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.Properties;
+
+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() {
+ Properties properties = new 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() {
+ Properties properties = new 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/AbstractAgent.java b/components/camel-ai/camel-langchain4j-agent-api/src/main/java/org/apache/camel/component/langchain4j/agent/api/AbstractAgent.java
index 34fd61f2a1180..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
@@ -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,16 @@ protected AgentConfiguration getConfiguration() {
return configuration;
}
+ /**
+ * 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();
+ }
+
/**
* Returns the MCP tool provider filter from the given agent's configuration.
*
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..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
@@ -56,6 +56,14 @@ 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. 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.",
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..310cfcdb235b1 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-api
+ ${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..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
@@ -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. 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" }
},
"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..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
@@ -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,48 @@ 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(),
+ null));
+ } 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();
+ }
+ if (agent instanceof AbstractAgent> abstractAgent) {
+ return abstractAgent.getChatModel();
+ }
+ return null;
}
- 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);
+ }
+
if (result.finishReason() != null) {
message.setHeader(Headers.FINISH_REASON, result.finishReason());
}
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 d964c3f866567..c1cea4ad8cf15 100644
--- a/components/camel-ai/camel-langchain4j-chat/pom.xml
+++ b/components/camel-ai/camel-langchain4j-chat/pom.xml
@@ -63,12 +63,34 @@
${jackson2-version}
+
+ org.apache.camel
+ 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
+ test
+
dev.langchain4j
langchain4j-ollama
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-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..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
@@ -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 = GenAiModelResolver.resolveResponseModelName(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(),
+ GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel())));
+ return extractAiResponse(chatResponse.aiMessage());
+ } catch (RuntimeException e) {
+ observation.recordError(e);
+ throw e;
+ } finally {
+ observation.close();
+ }
}
/**
@@ -182,10 +210,34 @@ 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(),
+ GenAiModelResolver.resolveResponseModelName(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 String extractAiResponse(AiMessage response) {
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/pom.xml b/components/camel-ai/camel-langchain4j-embeddings/pom.xml
index 0a7f70cf635e0..30e2e658bcbdd 100644
--- a/components/camel-ai/camel-langchain4j-embeddings/pom.xml
+++ b/components/camel-ai/camel-langchain4j-embeddings/pom.xml
@@ -66,7 +66,24 @@
${langchain4j-beta-version}
+
+ org.apache.camel
+ 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/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-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..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
@@ -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,34 @@ 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(),
+ null));
+ } 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);
+ }
if (result.finishReason() != null) {
message.setHeader(LangChain4jEmbeddingsHeaders.FINISH_REASON, result.finishReason());
@@ -52,7 +84,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-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 8df6fd58d29d9..f3b13a375e4e6 100644
--- a/components/camel-ai/camel-langchain4j-tools/pom.xml
+++ b/components/camel-ai/camel-langchain4j-tools/pom.xml
@@ -61,12 +61,29 @@
${langchain4j-version}
+
+ org.apache.camel
+ camel-ai-observability-api
+ ${project.version}
+
+
org.apache.camel
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/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" },
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..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
@@ -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,41 @@ 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(),
+ GenAiModelResolver.resolveResponseModelName(chatResponse, observationContext.requestModel())));
+ exchange.getMessage().setHeader(LangChain4jToolsHeaders.RESPONSE_MODEL,
+ GenAiModelResolver.resolveResponseModelName(chatResponse, 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-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 9bafddb02bd22..8b2b72997e87d 100644
--- a/components/camel-ai/camel-openai/pom.xml
+++ b/components/camel-ai/camel-openai/pom.xml
@@ -59,7 +59,24 @@
${mcp-java-sdk-version}
+
+ org.apache.camel
+ 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/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..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
@@ -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;
@@ -56,6 +58,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 +469,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 +505,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);
@@ -602,23 +609,68 @@ private void enforceAgenticTokenBudget(
}
private void processStreaming(Exchange exchange, ChatCompletionCreateParams params) {
+ String requestModel = params.model().toString();
+ 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")
+ .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);
+ .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) {
+ CompletionUsage usage = usageRef.get();
+ String responseModel = responseModelRef.get() != null ? responseModelRef.get() : requestModel;
+ observation.recordSuccess(GenAiUsage.of(
+ usage != null ? toTokenCount(usage.promptTokens()) : null,
+ usage != null ? toTokenCount(usage.completionTokens()) : null,
+ null,
+ responseModel));
+ observation.close();
safeClose();
}
@Override
public void onFailure(Exchange e) {
+ if (e.getException() != null) {
+ observation.recordError(e.getException());
+ }
+ observation.close();
safeClose();
}
@@ -655,6 +707,36 @@ 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()
+ .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 ? toTokenCount(usage.promptTokens()) : null,
+ usage != null ? toTokenCount(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/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 8325bc01e2c0e..b83cf19064433 100644
--- a/components/camel-ai/pom.xml
+++ b/components/camel-ai/pom.xml
@@ -35,6 +35,8 @@
camel-a2a
+ camel-ai-observability-api
+ camel-ai-observability
camel-ai-tool
camel-chatscript
camel-djl
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/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..35491a98fa231 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 a6b8fb0663265..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
@@ -269,6 +269,21 @@ 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).
+
+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
The `Agent.chat()` method return type has changed from `String` to `Result` (from `dev.langchain4j.service.Result`).
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..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
@@ -556,6 +556,31 @@ 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. Not set by the agent producer when
+ * langchain4j Result does not expose it.
+ *
+ * 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 {
diff --git a/parent/pom.xml b/parent/pom.xml
index 1a8f16f90ce72..7924b908bf044 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -717,6 +717,16 @@
camel-activemq6
${project.version}
+
+ org.apache.camel
+ camel-ai-observability
+ ${project.version}
+
+
+ org.apache.camel
+ camel-ai-observability-api
+ ${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"),