This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 150d8725f45d CAMEL-23706: camel cmd span - Add trace-grouped view and
ASCII waterfall
150d8725f45d is described below
commit 150d8725f45d3023334c979281a0d522fc0e4630
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jun 8 17:31:06 2026 +0200
CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall
Rewrite `camel cmd span` to match TUI SpansTab capabilities:
- Default view shows trace-grouped summaries with ROUTE, FROM,
SPANS, ROUTES, REMOTE, STATUS, and DURATION columns
- New --trace=<id> option renders an ASCII waterfall for a specific
trace with span collapsing, Jansi colors, and duration bars
- New --flat flag preserves the original per-span list view
- Updated --sort and --filter for trace-level columns
- Increased default limit from 100 to 500
- Removed span collapsing hack now that core tracer skips redundant
PROCESS spans (CAMEL-23709 EndpointSending)
Closes #23826
---
.../pages/jbang-commands/camel-jbang-cmd-span.adoc | 8 +-
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../core/commands/action/CamelSpanAction.java | 537 ++++++++++++++++++++-
3 files changed, 525 insertions(+), 22 deletions(-)
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
index 0bd6f3271913..583823e775d2 100644
---
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
@@ -19,10 +19,12 @@ camel cmd span [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
+| `--filter` | Filter by trace ID, route, component, or exchange ID (substring
match) | | String
+| `--flat` | Show flat list of individual spans instead of grouped traces | |
boolean
+| `--limit` | Maximum number of spans to display | 500 | int
| `--logging-color` | Use colored logging | true | boolean
-| `--sort` | Sort by name, duration, or status | | String
+| `--sort` | Sort by trace, route, from, spans, routes, status, or duration |
| String
+| `--trace` | Show waterfall view for a specific trace ID (substring match) |
| String
| `-h,--help` | Display the help and sub-commands | | boolean
|===
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 cd8c5f4cd094..817a2a70d40c 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/action/CamelSpanAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelSpanAction.java
index 89c1bd958d30..cdeb69bcc772 100644
---
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
@@ -19,8 +19,14 @@ 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.Comparator;
+import java.util.HashSet;
import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import com.github.freva.asciitable.AsciiTable;
import com.github.freva.asciitable.Column;
@@ -33,6 +39,7 @@ 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 org.jline.jansi.Ansi;
import picocli.CommandLine;
@CommandLine.Command(name = "span",
@@ -41,8 +48,10 @@ import picocli.CommandLine;
footer = {
"%nExamples:",
" camel cmd span",
- " camel cmd span --limit=50",
- " camel cmd span --filter=direct" })
+ " camel cmd span --sort=duration",
+ " camel cmd span --trace=4bb73039",
+ " camel cmd span --flat",
+ " camel cmd span --filter=kafka" })
public class CamelSpanAction extends ActionBaseCommand {
public static class SortCompletionCandidates implements Iterable<String> {
@@ -52,25 +61,33 @@ public class CamelSpanAction extends ActionBaseCommand {
@Override
public Iterator<String> iterator() {
- return List.of("name", "duration", "status").iterator();
+ return List.of("trace", "route", "from", "spans", "routes",
"status", "duration").iterator();
}
}
@CommandLine.Parameters(description = "Name or pid of running Camel
integration", arity = "0..1")
String name = "*";
- @CommandLine.Option(names = { "--limit" }, defaultValue = "100",
+ @CommandLine.Option(names = { "--limit" }, defaultValue = "500",
description = "Maximum number of spans to display")
- int limit = 100;
+ int limit = 500;
@CommandLine.Option(names = { "--filter" },
- description = "Filter spans by name (substring match)")
+ description = "Filter by trace ID, route, component,
or exchange ID (substring match)")
String filter;
@CommandLine.Option(names = { "--sort" }, completionCandidates =
SortCompletionCandidates.class,
- description = "Sort by name, duration, or status")
+ description = "Sort by trace, route, from, spans,
routes, status, or duration")
String sort;
+ @CommandLine.Option(names = { "--trace" },
+ description = "Show waterfall view for a specific
trace ID (substring match)")
+ String trace;
+
+ @CommandLine.Option(names = { "--flat" },
+ description = "Show flat list of individual spans
instead of grouped traces")
+ boolean flat;
+
@CommandLine.Option(names = { "--logging-color" }, defaultValue = "true",
description = "Use colored logging")
boolean loggingColor = true;
@@ -158,19 +175,38 @@ public class CamelSpanAction extends ActionBaseCommand {
row.status = span.getString("status");
Long durationMs = span.getLong("durationMs");
row.durationMs = durationMs != null ? durationMs : 0;
-
- if (filter != null && !matchesFilter(row.spanName, filter)) {
- continue;
+ row.routeId = span.getString("routeId");
+ row.processorId = span.getString("processorId");
+ row.startEpochNanos = span.getLongOrDefault("startEpochNanos",
0);
+ row.endEpochNanos = span.getLongOrDefault("endEpochNanos", 0);
+ JsonObject attrsObj = span.getMap("attributes");
+ if (attrsObj != null && !attrsObj.isEmpty()) {
+ row.attributes = attrsObj;
}
rows.add(row);
}
- if (sort != null) {
- rows.sort(this::sortRow);
+ if (trace != null) {
+ printWaterfall(rows, trace);
+ } else if (flat) {
+ if (filter != null) {
+ rows.removeIf(r -> !matchesFilter(r.spanName, filter));
+ }
+ if (sort != null) {
+ rows.sort(this::sortRow);
+ }
+ tableSpans(rows);
+ } else {
+ List<TraceSummary> summaries = buildTraceSummaries(rows);
+ if (filter != null) {
+ summaries.removeIf(ts ->
!ts.searchText.contains(filter.toLowerCase()));
+ }
+ if (sort != null) {
+ summaries.sort((a, b) -> sortTraceSummary(a, b, rows));
+ }
+ tableTraces(summaries);
}
-
- tableSpans(rows);
} else {
printer().printErr("Response from running Camel with PID " + pid +
" not received within 5 seconds");
return 1;
@@ -180,13 +216,354 @@ public class CamelSpanAction extends ActionBaseCommand {
return 0;
}
- private boolean matchesFilter(String spanName, String pattern) {
- if (spanName == null) {
- return false;
+ // --- Trace-grouped view ---
+
+ private List<TraceSummary> buildTraceSummaries(List<Row> rows) {
+ Map<String, TraceSummary> byTrace = new LinkedHashMap<>();
+
+ for (Row row : rows) {
+ TraceSummary ts = byTrace.computeIfAbsent(row.traceId,
TraceSummary::new);
+ if (isRoot(row)) {
+ ts.rootRouteId = row.routeId;
+ ts.rootName = compactUri(row);
+ }
+ if ("ERROR".equals(row.status)) {
+ ts.hasError = true;
+ }
+ }
+
+ List<TraceSummary> result = new ArrayList<>(byTrace.values());
+ for (TraceSummary ts : result) {
+ List<Row> traceRows = rows.stream()
+ .filter(r -> r.traceId.equals(ts.traceId))
+ .toList();
+ // Fallback root: use earliest span
+ if (ts.rootName == null && !traceRows.isEmpty()) {
+ Row earliest = traceRows.stream()
+ .min(Comparator.comparingLong(r -> r.startEpochNanos))
+ .orElse(null);
+ if (earliest != null) {
+ ts.rootName = compactUri(earliest);
+ if (ts.rootRouteId == null) {
+ ts.rootRouteId = earliest.routeId;
+ }
+ }
+ }
+ long traceStart = Long.MAX_VALUE;
+ long traceEnd = 0;
+ Set<String> routes = new HashSet<>();
+ Set<String> exchangeIds = new HashSet<>();
+ Set<String> remoteSchemes = new LinkedHashSet<>();
+ for (Row r : traceRows) {
+ traceStart = Math.min(traceStart, r.startEpochNanos);
+ traceEnd = Math.max(traceEnd, r.endEpochNanos);
+ if (r.routeId != null) {
+ routes.add(r.routeId);
+ }
+ if (r.attributes != null) {
+ Object eid = r.attributes.get("exchangeId");
+ if (eid != null) {
+ exchangeIds.add(eid.toString());
+ }
+ Object scheme = r.attributes.get("url.scheme");
+ if (scheme != null && isRemoteScheme(scheme.toString())) {
+ remoteSchemes.add(scheme.toString());
+ }
+ }
+ ts.spanCount++;
+ }
+ ts.totalDurationMs = traceStart < Long.MAX_VALUE ? (traceEnd -
traceStart) / 1_000_000 : 0;
+ ts.routeCount = routes.size();
+ ts.remoteComponents = remoteSchemes.isEmpty() ? "" :
String.join(",", remoteSchemes);
+ // Build search text
+ StringBuilder sb = new StringBuilder();
+ sb.append(ts.traceId).append(' ');
+ exchangeIds.forEach(e -> sb.append(e).append(' '));
+ routes.forEach(r -> sb.append(r).append(' '));
+ if (!ts.remoteComponents.isEmpty()) {
+ sb.append(ts.remoteComponents);
+ }
+ ts.searchText = sb.toString().toLowerCase();
+ }
+
+ // Default sort: newest first
+ result.sort((a, b) -> {
+ long at = rows.stream()
+ .filter(r -> r.traceId.equals(a.traceId))
+ .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+ long bt = rows.stream()
+ .filter(r -> r.traceId.equals(b.traceId))
+ .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+ return Long.compare(bt, at);
+ });
+
+ return result;
+ }
+
+ protected void tableTraces(List<TraceSummary> traces) {
+ int tw = terminalWidth();
+ int fixedWidth = 10 + 8 + 8 + 8 + 8 + 12;
+ int borderOverhead = TerminalWidthHelper.noBorderOverhead(8);
+ int remaining = tw - fixedWidth - borderOverhead;
+ int routeWidth = Math.max(10, Math.min(20, remaining / 3));
+ int fromWidth = Math.max(10, Math.min(30, remaining - routeWidth));
+
+ printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, traces,
Arrays.asList(
+ new
Column().header("TRACE-ID").headerAlign(HorizontalAlign.CENTER)
+ .with(ts -> shortId(ts.traceId)),
+ new Column().header("ROUTE").dataAlign(HorizontalAlign.LEFT)
+ .maxWidth(routeWidth, OverflowBehaviour.ELLIPSIS_RIGHT)
+ .with(ts -> ts.rootRouteId != null ? ts.rootRouteId :
""),
+ new Column().header("FROM").dataAlign(HorizontalAlign.LEFT)
+ .maxWidth(fromWidth, OverflowBehaviour.ELLIPSIS_RIGHT)
+ .with(ts -> ts.rootName != null ? ts.rootName : "?"),
+ new Column().header("SPANS").headerAlign(HorizontalAlign.RIGHT)
+ .dataAlign(HorizontalAlign.RIGHT)
+ .with(ts -> String.valueOf(ts.spanCount)),
+ new
Column().header("ROUTES").headerAlign(HorizontalAlign.RIGHT)
+ .dataAlign(HorizontalAlign.RIGHT)
+ .with(ts -> String.valueOf(ts.routeCount)),
+ new Column().header("REMOTE").dataAlign(HorizontalAlign.LEFT)
+ .with(ts -> ts.remoteComponents.isEmpty() ? "-" :
ts.remoteComponents),
+ new
Column().header("STATUS").headerAlign(HorizontalAlign.CENTER)
+ .with(ts -> ts.hasError ? "ERROR" : "OK"),
+ new
Column().header("DURATION").headerAlign(HorizontalAlign.RIGHT)
+ .dataAlign(HorizontalAlign.RIGHT)
+ .with(ts -> ts.totalDurationMs + "ms"))));
+ }
+
+ // --- Waterfall view ---
+
+ private void printWaterfall(List<Row> allRows, String traceIdMatch) {
+ // Find matching trace by substring
+ String matchedTraceId = null;
+ for (Row r : allRows) {
+ if (r.traceId != null && r.traceId.contains(traceIdMatch)) {
+ matchedTraceId = r.traceId;
+ break;
+ }
+ }
+ if (matchedTraceId == null) {
+ printer().println("No trace found matching '" + traceIdMatch +
"'");
+ return;
+ }
+
+ final String tid = matchedTraceId;
+ List<Row> traceRows = allRows.stream()
+ .filter(r -> tid.equals(r.traceId))
+ .sorted(Comparator.comparingLong(r -> r.startEpochNanos))
+ .toList();
+
+ List<WaterfallNode> nodes = buildWaterfallNodes(traceRows);
+ if (nodes.isEmpty()) {
+ printer().println("No spans in trace " + shortId(tid));
+ return;
+ }
+
+ long traceStart = Long.MAX_VALUE;
+ long traceEnd = 0;
+ long minDuration = Long.MAX_VALUE;
+ long maxDuration = 0;
+ for (WaterfallNode n : nodes) {
+ traceStart = Math.min(traceStart, n.row.startEpochNanos);
+ traceEnd = Math.max(traceEnd, n.row.endEpochNanos);
+ if (n.row.durationMs > 0) {
+ minDuration = Math.min(minDuration, n.row.durationMs);
+ maxDuration = Math.max(maxDuration, n.row.durationMs);
+ }
+ }
+ if (minDuration == Long.MAX_VALUE) {
+ minDuration = 0;
+ }
+ long traceDuration = (traceEnd - traceStart) / 1_000_000;
+
+ printer().println();
+ if (loggingColor) {
+ printer().println(Ansi.ansi().bold()
+ .a("Trace ").a(shortId(tid)).a(" — ")
+ .a(nodes.size()).a(" spans, ").a(traceDuration).a("ms")
+ .reset().toString());
+ } else {
+ printer().println("Trace " + shortId(tid) + " — " + nodes.size() +
" spans, " + traceDuration + "ms");
+ }
+ printer().println();
+
+ int tw = terminalWidth();
+ int labelWidth = 0;
+ for (WaterfallNode n : nodes) {
+ int indent = n.depth * 2;
+ labelWidth = Math.max(labelWidth, indent +
spanLabel(n.row).length());
+ }
+ labelWidth = Math.min(labelWidth + 2, tw / 3);
+ int barMaxWidth = Math.max(10, tw - labelWidth - 12);
+
+ for (WaterfallNode n : nodes) {
+ printWaterfallLine(n, labelWidth, barMaxWidth, traceStart,
traceDuration, minDuration, maxDuration);
+ }
+ printer().println();
+ }
+
+ private void printWaterfallLine(
+ WaterfallNode node, int labelWidth, int maxBarWidth,
+ long traceStart, long traceDuration, long minDuration, long
maxDuration) {
+
+ String indent = " ".repeat(node.depth);
+ String label = indent + spanLabel(node.row);
+ if (label.length() > labelWidth) {
+ label = label.substring(0, labelWidth - 1) + "…";
+ } else {
+ label = String.format("%-" + labelWidth + "s", label);
+ }
+
+ long spanStart = node.row.startEpochNanos - traceStart;
+ long spanDuration = node.row.endEpochNanos - node.row.startEpochNanos;
+
+ double offsetRatio = traceDuration > 0 ? (double) (spanStart /
1_000_000) / traceDuration : 0;
+ double widthRatio = traceDuration > 0 ? (double) (spanDuration /
1_000_000) / traceDuration : 0;
+
+ int barOffset = (int) Math.round(offsetRatio * maxBarWidth);
+ int barWidth = Math.max(1, (int) Math.round(widthRatio * maxBarWidth));
+ barOffset = Math.min(barOffset, maxBarWidth - 1);
+ barWidth = Math.min(barWidth, maxBarWidth - barOffset);
+
+ String gap = " ".repeat(barOffset);
+ String bar = "█".repeat(barWidth);
+ String durationStr = node.row.durationMs + "ms";
+ int pad = Math.max(1, 8 - durationStr.length());
+ boolean error = "ERROR".equals(node.row.status);
+
+ if (loggingColor) {
+ Ansi ansi = Ansi.ansi();
+ // Label
+ if (error) {
+ ansi.fgRed().a(label).reset();
+ } else {
+ ansi.fgCyan().a(label).reset();
+ }
+ // Gap + bar
+ ansi.a(gap);
+ if (error) {
+ ansi.fgRed().a(bar).reset();
+ } else {
+ ansi.fg(colorForDuration(node.row.durationMs, minDuration,
maxDuration)).a(bar).reset();
+ }
+ // Error tag
+ if (error) {
+ ansi.fgBrightRed().bold().a(" ERR").reset();
+ }
+ // Duration
+ ansi.a(" ".repeat(pad));
+ if (error) {
+ ansi.fgBrightRed().bold().a(durationStr).reset();
+ } else {
+ ansi.bold().a(durationStr).reset();
+ }
+ printer().println(ansi.toString());
+ } else {
+ String errorTag = error ? " ERR" : "";
+ printer().println(label + gap + bar + errorTag + " ".repeat(pad) +
durationStr);
}
- return spanName.toLowerCase().contains(pattern.toLowerCase());
}
+ private static Ansi.Color colorForDuration(long duration, long
minDuration, long maxDuration) {
+ if (maxDuration <= minDuration) {
+ return Ansi.Color.GREEN;
+ }
+ double ratio = (double) (duration - minDuration) / (maxDuration -
minDuration);
+ if (ratio < 0.33) {
+ return Ansi.Color.GREEN;
+ } else if (ratio < 0.66) {
+ return Ansi.Color.YELLOW;
+ } else {
+ return Ansi.Color.RED;
+ }
+ }
+
+ private List<WaterfallNode> buildWaterfallNodes(List<Row> traceRows) {
+ if (traceRows.isEmpty()) {
+ return List.of();
+ }
+
+ Map<String, List<Row>> childrenMap = new LinkedHashMap<>();
+ Row root = null;
+ for (Row row : traceRows) {
+ if (isRoot(row)) {
+ root = row;
+ }
+ String parentId = row.parentSpanId;
+ if (parentId != null && !parentId.isEmpty()) {
+ childrenMap.computeIfAbsent(parentId, k -> new
ArrayList<>()).add(row);
+ }
+ }
+ if (root == null) {
+ root = traceRows.get(0);
+ }
+
+ Set<String> included = new HashSet<>();
+ Map<String, Integer> spanIdToDepth = new LinkedHashMap<>();
+ List<WaterfallNode> result = new ArrayList<>();
+ addToWaterfall(result, root, childrenMap, 0, included, spanIdToDepth);
+
+ // Add orphan spans — insert after their parent when possible
+ boolean changed = true;
+ while (changed) {
+ changed = false;
+ for (Row row : traceRows) {
+ if (included.contains(row.spanId)) {
+ continue;
+ }
+ int depth = 0;
+ int insertIdx = result.size();
+ if (row.parentSpanId != null &&
spanIdToDepth.containsKey(row.parentSpanId)) {
+ depth = spanIdToDepth.get(row.parentSpanId) + 1;
+ // Find parent position and insert after it and its subtree
+ for (int i = 0; i < result.size(); i++) {
+ if (result.get(i).row.spanId.equals(row.parentSpanId))
{
+ int j = i + 1;
+ while (j < result.size() && result.get(j).depth >
result.get(i).depth) {
+ j++;
+ }
+ insertIdx = j;
+ break;
+ }
+ }
+ }
+ result.add(insertIdx, new WaterfallNode(row, depth));
+ included.add(row.spanId);
+ spanIdToDepth.put(row.spanId, depth);
+ changed = true;
+ }
+ }
+ return result;
+ }
+
+ private void addToWaterfall(
+ List<WaterfallNode> result, Row row,
+ Map<String, List<Row>> childrenMap, int depth,
+ Set<String> included, Map<String, Integer> spanIdToDepth) {
+ if (!included.add(row.spanId)) {
+ return;
+ }
+ spanIdToDepth.put(row.spanId, depth);
+
+ List<Row> children = childrenMap.get(row.spanId);
+ // Collapse EVENT_SENT → EVENT_RECEIVED pairs
+ if (isEventSent(row) && !"ERROR".equals(row.status) && children !=
null && children.size() == 1
+ && isEventReceived(children.get(0))
+ && row.spanName != null &&
row.spanName.equals(children.get(0).spanName)) {
+ addToWaterfall(result, children.get(0), childrenMap, depth,
included, spanIdToDepth);
+ return;
+ }
+ result.add(new WaterfallNode(row, depth));
+ if (children != null) {
+ for (Row child : children) {
+ addToWaterfall(result, child, childrenMap, depth + 1,
included, spanIdToDepth);
+ }
+ }
+ }
+
+ // --- Flat span view (original) ---
+
protected void tableSpans(List<Row> rows) {
int tw = terminalWidth();
int fixedWidth = 10 + 10 + 10 + 12 + 8 + 10;
@@ -212,6 +589,8 @@ public class CamelSpanAction extends ActionBaseCommand {
.with(r -> r.durationMs + "ms"))));
}
+ // --- Sort ---
+
protected int sortRow(Row o1, Row o2) {
String s = sort;
int negate = 1;
@@ -231,6 +610,103 @@ public class CamelSpanAction extends ActionBaseCommand {
}
}
+ private int sortTraceSummary(TraceSummary a, TraceSummary b, List<Row>
rows) {
+ String s = sort;
+ int negate = 1;
+ if (s.startsWith("-")) {
+ s = s.substring(1);
+ negate = -1;
+ }
+ int cmp = switch (s) {
+ case "route" -> compareNullSafe(a.rootRouteId, b.rootRouteId);
+ case "from" -> compareNullSafe(a.rootName, b.rootName);
+ case "duration" -> Long.compare(b.totalDurationMs,
a.totalDurationMs);
+ case "spans" -> Integer.compare(b.spanCount, a.spanCount);
+ case "routes" -> Integer.compare(b.routeCount, a.routeCount);
+ case "status" -> {
+ int as = a.hasError ? 1 : 0;
+ int bs = b.hasError ? 1 : 0;
+ yield Integer.compare(bs, as);
+ }
+ default -> {
+ // "trace" or unknown = newest first
+ long at = rows.stream()
+ .filter(r -> r.traceId.equals(a.traceId))
+ .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+ long bt = rows.stream()
+ .filter(r -> r.traceId.equals(b.traceId))
+ .mapToLong(r -> r.startEpochNanos).max().orElse(0);
+ yield Long.compare(bt, at);
+ }
+ };
+ return cmp * negate;
+ }
+
+ // --- Helpers ---
+
+ private boolean matchesFilter(String spanName, String pattern) {
+ if (spanName == null) {
+ return false;
+ }
+ return spanName.toLowerCase().contains(pattern.toLowerCase());
+ }
+
+ private static boolean isRoot(Row row) {
+ return row.parentSpanId == null || row.parentSpanId.isEmpty();
+ }
+
+ private static boolean isEventSent(Row row) {
+ return row.attributes != null &&
"EVENT_SENT".equals(row.attributes.get("op"));
+ }
+
+ private static boolean isEventReceived(Row row) {
+ return row.attributes != null &&
"EVENT_RECEIVED".equals(row.attributes.get("op"));
+ }
+
+ private static boolean isRemoteScheme(String scheme) {
+ return scheme != null
+ && !"direct".equals(scheme) && !"seda".equals(scheme)
+ && !"mock".equals(scheme) && !"log".equals(scheme)
+ && !"bean".equals(scheme) && !"class".equals(scheme);
+ }
+
+ private static String spanLabel(Row row) {
+ if (row.attributes != null) {
+ Object uri = row.attributes.get("camel.uri");
+ if (uri != null) {
+ String label = uri.toString();
+ if (row.routeId != null) {
+ label += " (" + row.routeId + ")";
+ }
+ return label;
+ }
+ }
+ if (row.processorId != null) {
+ String label = row.processorId;
+ if (row.routeId != null) {
+ label += " (" + row.routeId + ")";
+ }
+ return label;
+ }
+ return row.spanName != null ? row.spanName : "";
+ }
+
+ private static String compactUri(Row row) {
+ if (row.attributes != null) {
+ Object uri = row.attributes.get("camel.uri");
+ if (uri != null) {
+ String s = uri.toString();
+ s = s.replace("://", ":");
+ int q = s.indexOf('?');
+ if (q > 0) {
+ s = s.substring(0, q);
+ }
+ return s;
+ }
+ }
+ return row.spanName;
+ }
+
private static int compareNullSafe(String a, String b) {
if (a == null && b == null) {
return 0;
@@ -254,6 +730,8 @@ public class CamelSpanAction extends ActionBaseCommand {
return id;
}
+ // --- Inner classes ---
+
private static class Row {
String pid;
String name;
@@ -265,6 +743,29 @@ public class CamelSpanAction extends ActionBaseCommand {
String kind;
String status;
long durationMs;
+ String routeId;
+ String processorId;
+ long startEpochNanos;
+ long endEpochNanos;
+ Map<String, Object> attributes;
}
+ private static class TraceSummary {
+ final String traceId;
+ String rootRouteId;
+ String rootName;
+ int spanCount;
+ long totalDurationMs;
+ boolean hasError;
+ int routeCount;
+ String remoteComponents = "";
+ String searchText = "";
+
+ TraceSummary(String traceId) {
+ this.traceId = traceId;
+ }
+ }
+
+ private record WaterfallNode(Row row, int depth) {
+ }
}