more
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m35s

This commit is contained in:
mats committed 2026-09-19 20:06:36 +02:00
1 parent 8f670dfa2f
commit 2237fbda55
15 files changed
+594 -52

No files matched your search

+116 -14
View File
@@ -21,6 +21,7 @@ import {
import type { TraceData, Span } from "../lib/types";
import { formatDuration, formatRelativeTime } from "../lib/timeUtils";
import { formatAttributeValue, getServicePalette, getSpanColor } from "../lib/otel-utils";
import { extractEndpoint } from "../lib/http-endpoint";
import { useLogsForTraceId } from "../hooks/useLogsForTraceId";
import { useObservabilityData } from "../hooks/useObservabilityData";
import { LogEntryView } from "./Tabs";
@@ -47,6 +48,8 @@ export type ProcessedTrace = {
services: string[];
hasError: boolean;
errorCount: number;
/** Normalized HTTP operation used for display/grouping; empty for non-HTTP traces. */
endpoint: string;
};
// Flattened item for virtualized tree rendering
@@ -129,20 +132,26 @@ const flattenSpanTree = (
return result;
};
const getSpanEndpointLabel = (span: ProcessedSpan): string => {
const endpoint = extractEndpoint((...keys) => {
for (const key of keys) {
const value = span.formattedAttributes[key];
if (value) return value;
}
return "";
});
const path = endpoint.route || endpoint.path;
return path ? `${endpoint.method || "HTTP"} ${path}` : "";
};
const getOperationDisplayName = (span: ProcessedSpan) => {
let name = span.name;
if (!name || name === "/" || name === "GET /") {
const method = span.formattedAttributes["http.method"];
const target = span.formattedAttributes["http.target"];
const route = span.formattedAttributes["http.route"];
const urlPath = span.formattedAttributes["url.path"];
const endpointLabel = getSpanEndpointLabel(span);
const rpcMethod = span.formattedAttributes["rpc.method"];
if (route) name = route;
else if (method && target) name = `${method} ${target}`;
if (endpointLabel) name = endpointLabel;
else if (rpcMethod) name = rpcMethod;
else if (urlPath) name = urlPath;
else if (method) name = method;
}
return name || "span";
};
@@ -1211,6 +1220,9 @@ const TraceDirectoryTable = ({
onSortByChange,
allServices,
onSwitchToWaterfall,
groupByEndpoint,
groupCounts,
onGroupByEndpoint,
}: {
traces: ProcessedTrace[];
activeTraceId: string | null;
@@ -1225,8 +1237,13 @@ const TraceDirectoryTable = ({
onSortByChange: (s: any) => void;
allServices: string[];
onSwitchToWaterfall: () => void;
groupByEndpoint: boolean;
groupCounts: Record<string, number>;
onGroupByEndpoint: (grouped: boolean) => void;
}) => {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [page, setPage] = useState(0);
const pageSize = 50;
const copyTraceId = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -1243,6 +1260,17 @@ const TraceDirectoryTable = ({
return traces.filter((t) => t.hasError).length;
}, [traces]);
const pageCount = Math.max(1, Math.ceil(traces.length / pageSize));
const visibleTraces = traces.slice(page * pageSize, (page + 1) * pageSize);
useEffect(() => {
setPage(0);
}, [searchQuery, filter, serviceFilter, sortBy, groupByEndpoint]);
useEffect(() => {
setPage((current) => Math.min(current, pageCount - 1));
}, [pageCount]);
return (
<div className="space-y-4 rounded-[14px] border border-ds-line bg-ds-surface-2 p-5">
{/* Directory Header & Toolbar */}
@@ -1263,7 +1291,7 @@ const TraceDirectoryTable = ({
</span>
)}
</div>
<p className="text-xs text-ds-text-2">
<p className="text-xs text-ds-text-2">
Browse, search, and inspect distributed trace execution flows
</p>
</div>
@@ -1362,6 +1390,18 @@ const TraceDirectoryTable = ({
</div>
)}
<button
type="button"
onClick={() => onGroupByEndpoint(!groupByEndpoint)}
className={`rounded-xl border px-2.5 py-1 text-[11px] font-bold ${
groupByEndpoint
? "border-ds-accent/40 bg-ds-accent-bg text-ds-accent"
: "border-ds-line bg-ds-surface-2 text-ds-text-2"
}`}
>
{groupByEndpoint ? "Grouped by endpoint" : "Group endpoints"}
</button>
{/* Sort Dropdown */}
<div className="flex items-center gap-1 bg-ds-surface-2 px-2.5 py-1 rounded-xl border border-ds-line">
<ArrowUpDown className="h-3 w-3 text-ds-text-3" />
@@ -1411,7 +1451,7 @@ const TraceDirectoryTable = ({
</tr>
</thead>
<tbody className="divide-y divide-ds-line text-xs">
{traces.map((trace) => {
{visibleTraces.map((trace) => {
const isSelected = trace.id === activeTraceId;
const rootService = trace.spans[0]?.service || trace.services[0] || "unknown";
const palette = getServicePalette(rootService);
@@ -1455,8 +1495,13 @@ const TraceDirectoryTable = ({
{trace.name}
</span>
<span className="text-[10px] text-ds-text-3 font-mono">
root: {trace.spans[0]?.name || trace.name}
{trace.endpoint ? `endpoint: ${trace.endpoint}` : `root: ${trace.spans[0]?.name || trace.name}`}
</span>
{groupCounts[trace.endpoint] > 1 && (
<span className="text-[10px] font-mono text-ds-text-2">
{groupCounts[trace.endpoint]} traces in this endpoint group
</span>
)}
</div>
</td>
@@ -1548,6 +1593,33 @@ const TraceDirectoryTable = ({
</table>
</div>
)}
{traces.length > pageSize && (
<div className="flex items-center justify-between border-t border-ds-line pt-3 text-xs text-ds-text-2">
<span className="font-mono">
{page * pageSize + 1}–{Math.min((page + 1) * pageSize, traces.length)} of {traces.length}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((current) => Math.max(0, current - 1))}
disabled={page === 0}
className="rounded-lg border border-ds-line px-2.5 py-1 text-ds-text-2 disabled:opacity-40"
>
Previous
</button>
<span className="font-mono">Page {page + 1} / {pageCount}</span>
<button
type="button"
onClick={() => setPage((current) => Math.min(pageCount - 1, current + 1))}
disabled={page >= pageCount - 1}
className="rounded-lg border border-ds-line px-2.5 py-1 text-ds-text-2 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
);
};
@@ -1613,7 +1685,9 @@ export const processTraces = (
const traceList: ProcessedTrace[] = Object.entries(traceGroups).map(([id, spans]) => {
const orderedSpans = spans.sort((a, b) => a.startTime - b.startTime);
const displayName =
orderedSpans.find((span) => span.name)?.name || `Trace ${id.slice(0, 8)}`;
(orderedSpans.find((span) => span.name) &&
getOperationDisplayName(orderedSpans.find((span) => span.name)!)) ||
`Trace ${id.slice(0, 8)}`;
const startTime = orderedSpans.reduce(
(min, s) => Math.min(min, s.startTime),
Number.POSITIVE_INFINITY,
@@ -1625,6 +1699,9 @@ export const processTraces = (
const duration = Math.max(endTime - startTime, 0);
const services = Array.from(new Set(orderedSpans.map((s) => s.service)));
const errorCount = orderedSpans.filter((s) => s.hasError).length;
const endpointSpan =
orderedSpans.find((span) => span.kind === 2 && getSpanEndpointLabel(span)) ??
orderedSpans.find((span) => getSpanEndpointLabel(span));
return {
id,
@@ -1636,6 +1713,7 @@ export const processTraces = (
services,
hasError: errorCount > 0,
errorCount,
endpoint: endpointSpan ? getSpanEndpointLabel(endpointSpan) : "",
};
});
@@ -1665,6 +1743,7 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
const [traceSortBy, setTraceSortBy] = useState<
"recent" | "duration-desc" | "duration-asc" | "errors" | "spans"
>("recent");
const [groupByEndpoint, setGroupByEndpoint] = useState(false);
const orderedTraces: ProcessedTrace[] = useMemo(
() => processTraces(safeTraces, logCountBySpanId),
@@ -1702,6 +1781,27 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
});
}, [orderedTraces, traceFilter, traceServiceFilter, traceSearchQuery, traceSortBy]);
const groupCounts = useMemo(() => {
const counts: Record<string, number> = {};
filteredTraceList.forEach((trace) => {
if (trace.endpoint) counts[trace.endpoint] = (counts[trace.endpoint] ?? 0) + 1;
});
return counts;
}, [filteredTraceList]);
const directoryTraces = useMemo(() => {
if (!groupByEndpoint) return filteredTraceList;
const seen = new Set<string>();
return filteredTraceList.filter((trace) => {
// Non-HTTP traces stay individual; grouping them as "unknown" would be
// misleading and would hide otherwise unrelated trace types.
const key = trace.endpoint || `trace:${trace.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}, [filteredTraceList, groupByEndpoint]);
// Set default active trace if none selected or if active trace is outside current filtered list
useEffect(() => {
if (filteredTraceList.length > 0) {
@@ -1862,7 +1962,7 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
{/* Main View: Directory Table or Waterfall */}
{viewMode === "directory" ? (
<TraceDirectoryTable
traces={filteredTraceList}
traces={directoryTraces}
activeTraceId={activeTrace.id}
onSelectTrace={(id) => setActiveTraceId(id)}
searchQuery={traceSearchQuery}
@@ -1875,6 +1975,9 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
onSortByChange={setTraceSortBy}
allServices={allServices}
onSwitchToWaterfall={() => setViewMode("waterfall")}
groupByEndpoint={groupByEndpoint}
groupCounts={groupCounts}
onGroupByEndpoint={setGroupByEndpoint}
/>
) : (
<SingleTraceView key={activeTrace.id} trace={activeTrace} />
@@ -1884,4 +1987,3 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
};
export default TraceVisualization;