You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ac...@apache.org on 2026/07/24 07:12:03 UTC

(camel) branch main updated: CAMEL-24221: Add camel_runtime_ai_trace MCP tool

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 3f8d7e84cef7 CAMEL-24221: Add camel_runtime_ai_trace MCP tool
3f8d7e84cef7 is described below

commit 3f8d7e84cef70cb7de7a41cca38820bc58ba800a
Author: Andrea Cosentino <an...@gmail.com>
AuthorDate: Fri Jul 24 09:11:55 2026 +0200

    CAMEL-24221: Add camel_runtime_ai_trace MCP tool
    
    New MCP tool that traces AI-specific exchange flow in running Camel applications. Combines message history, top processor stats, and route structure to surface AI metrics: token usage, model IDs, guardrail outcomes, completion reasons, chunk counts, and per-component latency for Bedrock, LangChain4j, Docling, Textract, OpenAI, KServe, DJL, TensorFlow Serving, and HuggingFace components.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 .../dsl/jbang/core/commands/mcp/AiTraceTools.java  | 327 +++++++++++++++++++++
 .../jbang/core/commands/mcp/AiTraceToolsTest.java  | 231 +++++++++++++++
 2 files changed, 558 insertions(+)

diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
new file mode 100644
index 000000000000..c4597cc8a22c
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceTools.java
@@ -0,0 +1,327 @@
+/*
+ * 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.dsl.jbang.core.commands.mcp;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.mcp.server.Tool;
+import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolContext;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolExecutionException;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.jboss.logging.Logger;
+
+/**
+ * MCP Tool for tracing AI-specific exchange flow in running Camel applications.
+ * <p>
+ * Combines data from the message history, top processor statistics, and route structure to surface AI-specific metrics:
+ * token usage, model IDs, guardrail outcomes, completion reasons, and per-component latency for AI components (Bedrock,
+ * LangChain4j, Docling, Textract, OpenAI, KServe, DJL, TensorFlow Serving, HuggingFace).
+ */
+@ApplicationScoped
+public class AiTraceTools {
+
+    private static final Logger LOG = Logger.getLogger(AiTraceTools.class);
+
+    private static final String NAME_OR_PID_DESC
+            = "Name or PID of the Camel process. Leave empty to auto-detect (works when exactly one Camel process is running)";
+
+    private static final Set<String> AI_COMPONENT_SCHEMES = Set.of(
+            "aws-bedrock", "aws-bedrock-agent", "aws-bedrock-agent-runtime",
+            "aws2-textract", "docling",
+            "langchain4j-chat", "langchain4j-embeddings", "langchain4j-embeddingstore",
+            "langchain4j-tools", "langchain4j-agent", "langchain4j-web-search",
+            "openai", "kserve", "tensorflow-serving", "djl",
+            "huggingface", "ai-tool");
+
+    private static final Set<String> AI_HEADER_PREFIXES = Set.of(
+            "CamelAwsBedrock", "CamelAwsTextract",
+            "CamelLangChain4j", "CamelLangchain4j",
+            "CamelDocling", "CamelOpenAI",
+            "CamelKServe", "CamelDjl", "CamelTensorFlowServing", "CamelHuggingFace");
+
+    @Inject
+    RuntimeService runtimeService;
+
+    @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false),
+          description = "Trace AI-specific exchange flow in a running Camel application. "
+                        + "Shows token usage, model IDs, guardrail outcomes, completion reasons, "
+                        + "streaming chunk counts, and per-component latency for AI components "
+                        + "(Bedrock, LangChain4j, Docling, Textract, OpenAI, KServe, DJL, "
+                        + "TensorFlow Serving, HuggingFace). "
+                        + "Combines message history with processor statistics filtered to AI steps.")
+    public AiTraceResult camel_runtime_ai_trace(
+            @ToolArg(description = NAME_OR_PID_DESC) String nameOrPid,
+            @ToolArg(description = "Route ID filter (exact match or * for all, default: *)") String routeFilter) {
+
+        RuntimeService.ProcessInfo p = runtimeService.findSingleProcess(nameOrPid);
+        ToolContext ctx = new ToolContext();
+        ctx.selectProcess(p.pid());
+
+        String filter = routeFilter != null && !routeFilter.isBlank() ? routeFilter : "*";
+
+        try {
+            JsonObject history = executeRegistryTool("get_history", ctx, Map.of());
+            JsonObject topProcessors = executeRegistryTool("get_top_processors", ctx, Map.of());
+            JsonObject routeStructure
+                    = executeRegistryTool("get_route_structure", ctx, Map.of("routeId", filter));
+
+            List<AiComponentInfo> aiComponents = extractAiComponents(routeStructure);
+            List<AiHeaderInfo> aiHeaders = extractAiHeaders(history);
+            List<AiProcessorStat> aiProcessorStats = extractAiProcessorStats(topProcessors);
+            AiTraceSummary summary = buildSummary(aiComponents, aiHeaders, aiProcessorStats);
+
+            return new AiTraceResult(
+                    p.name(), p.pid(),
+                    aiComponents.isEmpty() ? null : aiComponents,
+                    aiHeaders.isEmpty() ? null : aiHeaders,
+                    aiProcessorStats.isEmpty() ? null : aiProcessorStats,
+                    summary);
+        } catch (ToolCallException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new ToolCallException(
+                    "Failed to collect AI trace data: " + e.getMessage(), null);
+        }
+    }
+
+    private JsonObject executeRegistryTool(String toolName, ToolContext ctx, Map<String, String> args) {
+        try {
+            Object result = ToolRegistry.execute(toolName, ctx, args);
+            return RuntimeTools.toJsonObject(result);
+        } catch (ToolExecutionException e) {
+            LOG.debugf("AI trace: %s query failed: %s", toolName, e.getMessage());
+            return new JsonObject();
+        }
+    }
+
+    List<AiComponentInfo> extractAiComponents(JsonObject routeStructure) {
+        List<AiComponentInfo> result = new ArrayList<>();
+        collectAiComponentsRecursive(routeStructure, result);
+        return result;
+    }
+
+    private void collectAiComponentsRecursive(Object node, List<AiComponentInfo> result) {
+        if (node instanceof JsonObject jo) {
+            String uri = jo.getString("uri");
+            if (uri != null) {
+                String scheme = extractScheme(uri);
+                if (scheme != null && AI_COMPONENT_SCHEMES.contains(scheme)) {
+                    result.add(new AiComponentInfo(
+                            scheme, uri,
+                            jo.getString("routeId"),
+                            jo.getString("nodeId")));
+                }
+            }
+            for (Object value : jo.values()) {
+                collectAiComponentsRecursive(value, result);
+            }
+        } else if (node instanceof JsonArray ja) {
+            for (Object item : ja) {
+                collectAiComponentsRecursive(item, result);
+            }
+        }
+    }
+
+    List<AiHeaderInfo> extractAiHeaders(JsonObject history) {
+        List<AiHeaderInfo> result = new ArrayList<>();
+        collectAiHeadersRecursive(history, result);
+        return result;
+    }
+
+    private void collectAiHeadersRecursive(Object node, List<AiHeaderInfo> result) {
+        if (node instanceof JsonObject jo) {
+            for (Map.Entry<String, Object> entry : jo.entrySet()) {
+                String key = entry.getKey();
+                if (isAiHeader(key) && entry.getValue() != null) {
+                    result.add(new AiHeaderInfo(
+                            key, truncateValue(entry.getValue().toString(), 200),
+                            categorizeHeader(key)));
+                }
+                collectAiHeadersRecursive(entry.getValue(), result);
+            }
+        } else if (node instanceof JsonArray ja) {
+            for (Object item : ja) {
+                collectAiHeadersRecursive(item, result);
+            }
+        }
+    }
+
+    List<AiProcessorStat> extractAiProcessorStats(JsonObject topProcessors) {
+        List<AiProcessorStat> result = new ArrayList<>();
+        collectAiProcessorStatsRecursive(topProcessors, result);
+        return result;
+    }
+
+    private void collectAiProcessorStatsRecursive(Object node, List<AiProcessorStat> result) {
+        if (node instanceof JsonObject jo) {
+            String id = jo.getString("id");
+            String processorUri = jo.getString("uri");
+            if (processorUri != null) {
+                String scheme = extractScheme(processorUri);
+                if (scheme != null && AI_COMPONENT_SCHEMES.contains(scheme)) {
+                    result.add(new AiProcessorStat(
+                            id != null ? id : "unknown",
+                            processorUri,
+                            jo.getString("total"),
+                            jo.getString("mean"),
+                            jo.getString("max"),
+                            jo.getString("min"),
+                            jo.getString("last")));
+                }
+            }
+            for (Object value : jo.values()) {
+                collectAiProcessorStatsRecursive(value, result);
+            }
+        } else if (node instanceof JsonArray ja) {
+            for (Object item : ja) {
+                collectAiProcessorStatsRecursive(item, result);
+            }
+        }
+    }
+
+    private AiTraceSummary buildSummary(
+            List<AiComponentInfo> components,
+            List<AiHeaderInfo> headers,
+            List<AiProcessorStat> stats) {
+
+        String tokenUsage = headers.stream()
+                .filter(h -> "token_usage".equals(h.category()))
+                .map(AiHeaderInfo::value)
+                .findFirst().orElse(null);
+        String modelId = headers.stream()
+                .filter(h -> "model".equals(h.category()))
+                .map(AiHeaderInfo::value)
+                .findFirst().orElse(null);
+        String completionReason = headers.stream()
+                .filter(h -> "completion".equals(h.category()))
+                .map(AiHeaderInfo::value)
+                .findFirst().orElse(null);
+        String guardrailAction = headers.stream()
+                .filter(h -> "guardrail".equals(h.category()))
+                .map(AiHeaderInfo::value)
+                .findFirst().orElse(null);
+        String chunkCount = headers.stream()
+                .filter(h -> "streaming".equals(h.category()))
+                .map(AiHeaderInfo::value)
+                .findFirst().orElse(null);
+
+        return new AiTraceSummary(
+                components.size(), stats.size(),
+                tokenUsage, modelId, completionReason,
+                guardrailAction, chunkCount,
+                components.isEmpty() ? "No AI components detected in running routes" : null);
+    }
+
+    static String extractScheme(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        int idx = uri.indexOf(':');
+        return idx > 0 ? uri.substring(0, idx) : null;
+    }
+
+    private boolean isAiHeader(String key) {
+        for (String prefix : AI_HEADER_PREFIXES) {
+            if (key.startsWith(prefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private String categorizeHeader(String header) {
+        if (header.contains("TokenCount") || header.contains("Usage") || header.contains("TokenUsage")) {
+            return "token_usage";
+        }
+        if (header.contains("Model")) {
+            return "model";
+        }
+        if (header.contains("Guardrail")) {
+            return "guardrail";
+        }
+        if (header.contains("CompletionReason") || header.contains("StopReason")
+                || header.contains("FinishReason")) {
+            return "completion";
+        }
+        if (header.contains("ChunkCount") || header.contains("Stream")) {
+            return "streaming";
+        }
+        if (header.contains("Operation")) {
+            return "operation";
+        }
+        return "other";
+    }
+
+    private static String truncateValue(String s, int maxLen) {
+        return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...";
+    }
+
+    // ---- Result records ----
+
+    record AiTraceResult(
+            String processName,
+            long pid,
+            List<AiComponentInfo> aiComponents,
+            List<AiHeaderInfo> aiHeaders,
+            List<AiProcessorStat> aiProcessorStats,
+            AiTraceSummary summary) {
+    }
+
+    record AiComponentInfo(
+            String scheme,
+            String uri,
+            String routeId,
+            String nodeId) {
+    }
+
+    record AiHeaderInfo(
+            String header,
+            String value,
+            String category) {
+    }
+
+    record AiProcessorStat(
+            String processorId,
+            String uri,
+            String totalExchanges,
+            String meanMs,
+            String maxMs,
+            String minMs,
+            String lastMs) {
+    }
+
+    record AiTraceSummary(
+            int aiComponentCount,
+            int aiProcessorStatCount,
+            String tokenUsage,
+            String modelId,
+            String completionReason,
+            String guardrailAction,
+            String chunkCount,
+            String note) {
+    }
+}
diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceToolsTest.java
new file mode 100644
index 000000000000..fd4283a29c50
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AiTraceToolsTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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.dsl.jbang.core.commands.mcp;
+
+import java.util.List;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AiTraceToolsTest {
+
+    private AiTraceTools tools;
+
+    @BeforeEach
+    void setUp() {
+        tools = new AiTraceTools();
+    }
+
+    @Test
+    void shouldExtractAiComponentsFromRouteStructure() {
+        JsonObject route = new JsonObject();
+        JsonArray steps = new JsonArray();
+
+        JsonObject bedrockStep = new JsonObject();
+        bedrockStep.put("uri", "aws-bedrock:label");
+        bedrockStep.put("routeId", "ai-route");
+        bedrockStep.put("nodeId", "to1");
+        steps.add(bedrockStep);
+
+        JsonObject directStep = new JsonObject();
+        directStep.put("uri", "direct:start");
+        steps.add(directStep);
+
+        JsonObject doclingStep = new JsonObject();
+        doclingStep.put("uri", "docling:convert");
+        doclingStep.put("routeId", "ai-route");
+        doclingStep.put("nodeId", "to2");
+        steps.add(doclingStep);
+
+        route.put("steps", steps);
+
+        List<AiTraceTools.AiComponentInfo> result = tools.extractAiComponents(route);
+
+        assertThat(result).hasSize(2);
+        assertThat(result.get(0).scheme()).isEqualTo("aws-bedrock");
+        assertThat(result.get(0).routeId()).isEqualTo("ai-route");
+        assertThat(result.get(1).scheme()).isEqualTo("docling");
+    }
+
+    @Test
+    void shouldIgnoreNonAiComponents() {
+        JsonObject route = new JsonObject();
+        route.put("uri", "kafka:my-topic");
+
+        List<AiTraceTools.AiComponentInfo> result = tools.extractAiComponents(route);
+
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    void shouldExtractAiHeaders() {
+        JsonObject history = new JsonObject();
+        JsonObject headers = new JsonObject();
+        headers.put("CamelAwsBedrockTokenCount", "1500");
+        headers.put("CamelAwsBedrockCompletionReason", "end_turn");
+        headers.put("CamelAwsBedrockChunkCount", "12");
+        headers.put("CamelAwsBedrockGuardrailOutput", "allowed");
+        headers.put("breadcrumbId", "abc-123");
+        history.put("headers", headers);
+
+        List<AiTraceTools.AiHeaderInfo> result = tools.extractAiHeaders(history);
+
+        assertThat(result).hasSize(4);
+        assertThat(result).extracting(AiTraceTools.AiHeaderInfo::header)
+                .containsExactlyInAnyOrder(
+                        "CamelAwsBedrockTokenCount",
+                        "CamelAwsBedrockCompletionReason",
+                        "CamelAwsBedrockChunkCount",
+                        "CamelAwsBedrockGuardrailOutput");
+    }
+
+    @Test
+    void shouldCategorizeHeadersCorrectly() {
+        JsonObject history = new JsonObject();
+        history.put("CamelAwsBedrockTokenCount", "500");
+        history.put("CamelAwsBedrockConverseUsage", "{input: 100, output: 400}");
+        history.put("CamelAwsBedrockCompletionReason", "end_turn");
+        history.put("CamelAwsBedrockChunkCount", "5");
+        history.put("CamelAwsBedrockGuardrailAssessments", "[]");
+        history.put("CamelAwsBedrockOperation", "converse");
+
+        List<AiTraceTools.AiHeaderInfo> result = tools.extractAiHeaders(history);
+
+        assertThat(result).anyMatch(h -> "token_usage".equals(h.category()));
+        assertThat(result).anyMatch(h -> "completion".equals(h.category()));
+        assertThat(result).anyMatch(h -> "streaming".equals(h.category()));
+        assertThat(result).anyMatch(h -> "guardrail".equals(h.category()));
+        assertThat(result).anyMatch(h -> "operation".equals(h.category()));
+    }
+
+    @Test
+    void shouldExtractAiProcessorStats() {
+        JsonObject top = new JsonObject();
+        JsonArray processors = new JsonArray();
+
+        JsonObject bedrockProc = new JsonObject();
+        bedrockProc.put("id", "to1");
+        bedrockProc.put("uri", "aws-bedrock:label");
+        bedrockProc.put("total", "42");
+        bedrockProc.put("mean", "1200");
+        bedrockProc.put("max", "3500");
+        bedrockProc.put("min", "450");
+        bedrockProc.put("last", "980");
+        processors.add(bedrockProc);
+
+        JsonObject kafkaProc = new JsonObject();
+        kafkaProc.put("id", "to2");
+        kafkaProc.put("uri", "kafka:output");
+        kafkaProc.put("total", "42");
+        processors.add(kafkaProc);
+
+        top.put("processors", processors);
+
+        List<AiTraceTools.AiProcessorStat> result = tools.extractAiProcessorStats(top);
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).uri()).isEqualTo("aws-bedrock:label");
+        assertThat(result.get(0).totalExchanges()).isEqualTo("42");
+        assertThat(result.get(0).meanMs()).isEqualTo("1200");
+    }
+
+    @Test
+    void shouldHandleEmptyInputGracefully() {
+        assertThat(tools.extractAiComponents(new JsonObject())).isEmpty();
+        assertThat(tools.extractAiHeaders(new JsonObject())).isEmpty();
+        assertThat(tools.extractAiProcessorStats(new JsonObject())).isEmpty();
+    }
+
+    @Test
+    void shouldExtractSchemeCorrectly() {
+        assertThat(AiTraceTools.extractScheme("aws-bedrock:label")).isEqualTo("aws-bedrock");
+        assertThat(AiTraceTools.extractScheme("docling:convert")).isEqualTo("docling");
+        assertThat(AiTraceTools.extractScheme("langchain4j-chat:model")).isEqualTo("langchain4j-chat");
+        assertThat(AiTraceTools.extractScheme(null)).isNull();
+        assertThat(AiTraceTools.extractScheme("noscheme")).isNull();
+    }
+
+    @Test
+    void shouldDetectLangChain4jComponents() {
+        JsonObject route = new JsonObject();
+        route.put("uri", "langchain4j-chat:myModel");
+
+        List<AiTraceTools.AiComponentInfo> result = tools.extractAiComponents(route);
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).scheme()).isEqualTo("langchain4j-chat");
+    }
+
+    @Test
+    void shouldDetectTextractComponents() {
+        JsonObject route = new JsonObject();
+        route.put("uri", "aws2-textract:detect");
+
+        List<AiTraceTools.AiComponentInfo> result = tools.extractAiComponents(route);
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).scheme()).isEqualTo("aws2-textract");
+    }
+
+    @Test
+    void shouldExtractOpenAiHeadersWithCorrectCasing() {
+        JsonObject history = new JsonObject();
+        history.put("CamelOpenAIModel", "gpt-4o");
+        history.put("CamelOpenAIFinishReason", "stop");
+        history.put("breadcrumbId", "abc-123");
+
+        List<AiTraceTools.AiHeaderInfo> result = tools.extractAiHeaders(history);
+
+        assertThat(result).hasSize(2);
+        assertThat(result).extracting(AiTraceTools.AiHeaderInfo::header)
+                .containsExactlyInAnyOrder("CamelOpenAIModel", "CamelOpenAIFinishReason");
+    }
+
+    @Test
+    void shouldDetectWebSearchAndEmbeddingStoreComponents() {
+        JsonObject route = new JsonObject();
+        JsonArray steps = new JsonArray();
+        JsonObject ws = new JsonObject();
+        ws.put("uri", "langchain4j-web-search:search");
+        steps.add(ws);
+        JsonObject es = new JsonObject();
+        es.put("uri", "langchain4j-embeddingstore:store");
+        steps.add(es);
+        route.put("steps", steps);
+
+        List<AiTraceTools.AiComponentInfo> result = tools.extractAiComponents(route);
+
+        assertThat(result).hasSize(2);
+        assertThat(result).extracting(AiTraceTools.AiComponentInfo::scheme)
+                .containsExactlyInAnyOrder("langchain4j-web-search", "langchain4j-embeddingstore");
+    }
+
+    @Test
+    void shouldExtractTextractHeaders() {
+        JsonObject history = new JsonObject();
+        history.put("CamelAwsTextractJobId", "job-123");
+
+        List<AiTraceTools.AiHeaderInfo> result = tools.extractAiHeaders(history);
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).header()).isEqualTo("CamelAwsTextractJobId");
+    }
+}