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; + } + +}
