This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-23706-cmd-span-waterfall in repository https://gitbox.apache.org/repos/asf/camel.git
commit 37a7a72bb2ffaac014943040d68e655d8e5969c8 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Jun 8 07:56:33 2026 +0200 CAMEL-23706: camel cmd span - Add trace-grouped view and ASCII waterfall Rewrite camel cmd span to match the TUI SpansTab capabilities: - Default view now 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 candidates: trace, route, from, spans, routes, status, duration - Expanded --filter to match traceId, exchangeId, routeId, and remote component names - Increased default limit from 100 to 500 Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../pages/jbang-commands/camel-jbang-cmd-span.adoc | 8 +- .../META-INF/camel-jbang-commands-metadata.json | 2 +- .../core/commands/action/CamelSpanAction.java | 549 ++++++++++++++++++++- 3 files changed, 537 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..15131f80cb2f 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,362 @@ 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; + } } - return spanName.toLowerCase().contains(pattern.toLowerCase()); + + 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()); + } + } + if (!isEventProcess(r)) { + 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); + } + } + + 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; + } + // Collapse EVENT_PROCESS → EVENT_SENT wrapper + if (isEventProcess(row) && !"ERROR".equals(row.status) && children != null && children.size() == 1 + && isEventSent(children.get(0))) { + 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 +597,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 +618,107 @@ 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 isEventProcess(Row row) { + return row.attributes != null && "EVENT_PROCESS".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 +742,8 @@ public class CamelSpanAction extends ActionBaseCommand { return id; } + // --- Inner classes --- + private static class Row { String pid; String name; @@ -265,6 +755,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) { + } }
