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

davsclaus pushed a commit to branch feature/CAMEL-23706-cli-span-command
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 298de3c46c3852ef20aee61a3827dd6f748cc9cf
Author: Claus Ibsen <[email protected]>
AuthorDate: Sun Jun 7 14:04:44 2026 +0200

    CAMEL-23706: Add camel cmd span CLI command for OTel span display
    
    Co-Authored-By: Claude <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../pages/jbang-commands/camel-jbang-cmd-span.adoc |  29 +++
 .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc |   1 +
 .../camel/cli/connector/LocalCliConnector.java     |  22 ++
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../dsl/jbang/core/commands/CamelJBangMain.java    |   1 +
 .../core/commands/action/CamelSpanAction.java      | 270 +++++++++++++++++++++
 6 files changed, 324 insertions(+), 1 deletion(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc
new file mode 100644
index 000000000000..0bd6f3271913
--- /dev/null
+++ 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-span.adoc
@@ -0,0 +1,29 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel cmd span
+
+Display OpenTelemetry spans from running Camel integrations
+
+
+== Usage
+
+[source,bash]
+----
+camel cmd span [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--filter` | Filter spans by name (substring match) |  | String
+| `--limit` | Maximum number of spans to display | 100 | int
+| `--logging-color` | Use colored logging | true | boolean
+| `--sort` | Sort by name, duration, or status |  | String
+| `-h,--help` | Display the help and sub-commands |  | boolean
+|===
+
+
diff --git 
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc 
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
index 4a9792305122..8687fdd9751c 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
@@ -32,6 +32,7 @@ camel cmd [options]
 | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | 
Dump Camel route structure
 | xref:jbang-commands/camel-jbang-cmd-route-topology.adoc[route-topology] | 
Display inter-route topology connections
 | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to 
endpoints
+| xref:jbang-commands/camel-jbang-cmd-span.adoc[span] | Display OpenTelemetry 
spans from running Camel integrations
 | xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start 
Camel route groups
 | xref:jbang-commands/camel-jbang-cmd-start-route.adoc[start-route] | Start 
Camel routes
 | xref:jbang-commands/camel-jbang-cmd-stop-group.adoc[stop-group] | Stop Camel 
route groups
diff --git 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
index ae024871ebd4..5fbb9eeb16ea 100644
--- 
a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
+++ 
b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java
@@ -365,6 +365,8 @@ public class LocalCliConnector extends ServiceSupport 
implements CliConnector, C
                 doActionTraceTask(root);
             } else if ("browse".equals(action)) {
                 doActionBrowseTask(root);
+            } else if ("span".equals(action)) {
+                doActionSpanTask(root);
             } else if ("receive".equals(action)) {
                 doActionReceiveTask(root);
             } else if ("readme".equals(action)) {
@@ -887,6 +889,26 @@ public class LocalCliConnector extends ServiceSupport 
implements CliConnector, C
         }
     }
 
+    private void doActionSpanTask(JsonObject root) throws IOException {
+        DevConsole dc = 
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
+                .resolveById("opentelemetry");
+        if (dc != null) {
+            Map<String, Object> params = new HashMap<>();
+            params.put("dump", "true");
+            String limit = root.getString("limit");
+            if (limit != null) {
+                params.put("limit", limit);
+            }
+            JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, 
params);
+            LOG.trace("Updating output file: {}", outputFile);
+            IOHelper.writeText(json.toJson(), outputFile);
+        } else {
+            JsonObject json = new JsonObject();
+            json.put("enabled", false);
+            IOHelper.writeText(json.toJson(), outputFile);
+        }
+    }
+
     private void doActionReceiveTask(JsonObject root) throws Exception {
         DevConsole dc = 
camelContext.getCamelContextExtension().getContextPlugin(DevConsoleRegistry.class)
                 .resolveById("receive");
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 019f53a12b41..cd8c5f4cd094 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
     { "name": "ask", "fullName": "ask", "description": "Ask a question about a 
running Camel application using AI", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names": 
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY, 
OPENAI_API_KEY, or LLM_API_KEY env vars", "javaType": "java.lang.String", 
"type": "string" }, { "names": "--api-type", "description": "API type: 
'ollama', 'openai', or 'anthropic'", "javaType": "LlmClient.ApiType", "type" 
[...]
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind 
source and sink Kamelets as a new Camel integration", "deprecated": true, 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": 
[ { "names": "--error-handler", "description": "Add error handler 
(none|log|sink:<endpoint>). Sink endpoints are expected in the format 
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet 
name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts 
from Camel Catalog", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ 
{ "names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"component", "fullName": "catalog component", "description": "List components 
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
-    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
+    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in 
the running Camel integrations, such as start\/stop route, or change logging 
levels.", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": 
"browse", "fullName": "cmd browse", "description": "Browse pending messages on 
endpoints [...]
     { "name": "completion", "fullName": "completion", "description": "Generate 
completion script for bash\/zsh", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": 
"-h,--help", "description": "Display the help and sub-commands", "javaType": 
"boolean", "type": "boolean" } ] },
     { "name": "config", "fullName": "config", "description": "Get and set user 
configuration values", "sourceClass": 
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { 
"names": "-h,--help", "description": "Display the help and sub-commands", 
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", 
"fullName": "config get", "description": "Display user configuration value", 
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
     { "name": "debug", "fullName": "debug", "description": "Debug local Camel 
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", 
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd 
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background", "description": "Run in the background", "defaultValue": 
"false", "javaType": "boolean", "type": "boolean" }, { "names": 
"--background-wait", "description": "To  [...]
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index 989fdd70e0cd..4f5a5ede5b51 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -119,6 +119,7 @@ public class CamelJBangMain implements Callable<Integer> {
                         .addSubcommand("route-structure", new CommandLine(new 
CamelRouteStructureAction(this)))
                         .addSubcommand("route-topology", new CommandLine(new 
CamelRouteTopologyAction(this)))
                         .addSubcommand("send", new CommandLine(new 
CamelSendAction(this)))
+                        .addSubcommand("span", new CommandLine(new 
CamelSpanAction(this)))
                         .addSubcommand("start-group", new CommandLine(new 
CamelRouteGroupStartAction(this)))
                         .addSubcommand("start-route", new CommandLine(new 
CamelRouteStartAction(this)))
                         .addSubcommand("stop-group", new CommandLine(new 
CamelRouteGroupStopAction(this)))
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
new file mode 100644
index 000000000000..89c1bd958d30
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
@@ -0,0 +1,270 @@
+/*
+ * 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.action;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+
+import com.github.freva.asciitable.AsciiTable;
+import com.github.freva.asciitable.Column;
+import com.github.freva.asciitable.HorizontalAlign;
+import com.github.freva.asciitable.OverflowBehaviour;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.dsl.jbang.core.common.ProcessHelper;
+import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import picocli.CommandLine;
+
[email protected](name = "span",
+                     description = "Display OpenTelemetry spans from running 
Camel integrations",
+                     sortOptions = false, showDefaultValues = true,
+                     footer = {
+                             "%nExamples:",
+                             "  camel cmd span",
+                             "  camel cmd span --limit=50",
+                             "  camel cmd span --filter=direct" })
+public class CamelSpanAction extends ActionBaseCommand {
+
+    public static class SortCompletionCandidates implements Iterable<String> {
+
+        public SortCompletionCandidates() {
+        }
+
+        @Override
+        public Iterator<String> iterator() {
+            return List.of("name", "duration", "status").iterator();
+        }
+    }
+
+    @CommandLine.Parameters(description = "Name or pid of running Camel 
integration", arity = "0..1")
+    String name = "*";
+
+    @CommandLine.Option(names = { "--limit" }, defaultValue = "100",
+                        description = "Maximum number of spans to display")
+    int limit = 100;
+
+    @CommandLine.Option(names = { "--filter" },
+                        description = "Filter spans by name (substring match)")
+    String filter;
+
+    @CommandLine.Option(names = { "--sort" }, completionCandidates = 
SortCompletionCandidates.class,
+                        description = "Sort by name, duration, or status")
+    String sort;
+
+    @CommandLine.Option(names = { "--logging-color" }, defaultValue = "true",
+                        description = "Use colored logging")
+    boolean loggingColor = true;
+
+    private volatile long pid;
+
+    public CamelSpanAction(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    public Integer doCall() throws Exception {
+        List<Long> pids = findPids(name);
+        if (pids.isEmpty()) {
+            return 1;
+        } else if (pids.size() > 1) {
+            printer().println("Name or pid " + name + " matches " + pids.size()
+                              + " running Camel integrations. Specify a name 
or PID that matches exactly one.");
+            return 1;
+        }
+
+        this.pid = pids.get(0);
+
+        Path outputFile = getOutputFile(Long.toString(pid));
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject root = new JsonObject();
+        root.put("action", "span");
+        root.put("dump", "true");
+        root.put("limit", Integer.toString(limit));
+
+        Path f = getActionFile(Long.toString(pid));
+        try {
+            PathUtils.writeTextSafely(root.toJson(), f);
+        } catch (Exception e) {
+            // ignore
+        }
+
+        JsonObject jo = getJsonObject(outputFile);
+        if (jo != null) {
+            Boolean enabled = jo.getBoolean("enabled");
+            if (enabled == null || !enabled) {
+                printer().println(
+                        "OpenTelemetry in-memory exporter is not enabled. Use 
--observe flag when running the integration.");
+                PathUtils.deleteFile(outputFile);
+                return 0;
+            }
+
+            JsonArray arr = jo.getCollection("spans");
+            if (arr == null || arr.isEmpty()) {
+                printer().println("No spans captured yet.");
+                PathUtils.deleteFile(outputFile);
+                return 0;
+            }
+
+            List<Row> rows = new ArrayList<>();
+            root = loadStatus(this.pid);
+            String integrationName = null;
+            String ago = null;
+            if (root != null) {
+                JsonObject context = (JsonObject) root.get("context");
+                if (context != null) {
+                    integrationName = context.getString("name");
+                    if ("CamelJBang".equals(integrationName)) {
+                        ProcessHandle ph = 
ProcessHandle.of(this.pid).orElse(null);
+                        integrationName = ProcessHelper.extractName(root, ph);
+                    }
+                }
+                ProcessHandle ph = ProcessHandle.of(this.pid).orElse(null);
+                long uptime = extractSince(ph);
+                ago = TimeUtils.printSince(uptime);
+            }
+
+            for (int i = 0; i < arr.size(); i++) {
+                JsonObject span = (JsonObject) arr.get(i);
+                Row row = new Row();
+                row.pid = Long.toString(this.pid);
+                row.name = integrationName;
+                row.ago = ago;
+                row.traceId = span.getString("traceId");
+                row.spanId = span.getString("spanId");
+                row.parentSpanId = span.getString("parentSpanId");
+                row.spanName = span.getString("name");
+                row.kind = span.getString("kind");
+                row.status = span.getString("status");
+                Long durationMs = span.getLong("durationMs");
+                row.durationMs = durationMs != null ? durationMs : 0;
+
+                if (filter != null && !matchesFilter(row.spanName, filter)) {
+                    continue;
+                }
+
+                rows.add(row);
+            }
+
+            if (sort != null) {
+                rows.sort(this::sortRow);
+            }
+
+            tableSpans(rows);
+        } else {
+            printer().printErr("Response from running Camel with PID " + pid + 
" not received within 5 seconds");
+            return 1;
+        }
+
+        PathUtils.deleteFile(outputFile);
+        return 0;
+    }
+
+    private boolean matchesFilter(String spanName, String pattern) {
+        if (spanName == null) {
+            return false;
+        }
+        return spanName.toLowerCase().contains(pattern.toLowerCase());
+    }
+
+    protected void tableSpans(List<Row> rows) {
+        int tw = terminalWidth();
+        int fixedWidth = 10 + 10 + 10 + 12 + 8 + 10;
+        int borderOverhead = TerminalWidthHelper.noBorderOverhead(7);
+        int nameWidth = TerminalWidthHelper.flexWidth(tw, fixedWidth, 
borderOverhead, 15, 60);
+
+        printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, 
Arrays.asList(
+                new 
Column().header("TRACE-ID").headerAlign(HorizontalAlign.CENTER)
+                        .with(r -> shortId(r.traceId)),
+                new 
Column().header("SPAN-ID").headerAlign(HorizontalAlign.CENTER)
+                        .with(r -> shortId(r.spanId)),
+                new 
Column().header("PARENT").headerAlign(HorizontalAlign.CENTER)
+                        .with(r -> shortId(r.parentSpanId)),
+                new Column().header("NAME").dataAlign(HorizontalAlign.LEFT)
+                        .maxWidth(nameWidth, OverflowBehaviour.ELLIPSIS_RIGHT)
+                        .with(r -> r.spanName),
+                new Column().header("KIND").headerAlign(HorizontalAlign.CENTER)
+                        .with(r -> r.kind),
+                new 
Column().header("STATUS").headerAlign(HorizontalAlign.CENTER)
+                        .with(r -> r.status),
+                new 
Column().header("DURATION").headerAlign(HorizontalAlign.RIGHT)
+                        .dataAlign(HorizontalAlign.RIGHT)
+                        .with(r -> r.durationMs + "ms"))));
+    }
+
+    protected int sortRow(Row o1, Row o2) {
+        String s = sort;
+        int negate = 1;
+        if (s.startsWith("-")) {
+            s = s.substring(1);
+            negate = -1;
+        }
+        switch (s) {
+            case "name":
+                return compareNullSafe(o1.spanName, o2.spanName) * negate;
+            case "duration":
+                return Long.compare(o1.durationMs, o2.durationMs) * negate;
+            case "status":
+                return compareNullSafe(o1.status, o2.status) * negate;
+            default:
+                return 0;
+        }
+    }
+
+    private static int compareNullSafe(String a, String b) {
+        if (a == null && b == null) {
+            return 0;
+        }
+        if (a == null) {
+            return -1;
+        }
+        if (b == null) {
+            return 1;
+        }
+        return a.compareToIgnoreCase(b);
+    }
+
+    private static String shortId(String id) {
+        if (id == null || id.isEmpty()) {
+            return "";
+        }
+        if (id.length() > 8) {
+            return id.substring(0, 8);
+        }
+        return id;
+    }
+
+    private static class Row {
+        String pid;
+        String name;
+        String ago;
+        String traceId;
+        String spanId;
+        String parentSpanId;
+        String spanName;
+        String kind;
+        String status;
+        long durationMs;
+    }
+
+}

Reply via email to