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"),