more
This commit is contained in:
1 parent
8f670dfa2f
commit
2237fbda55
15 files changed
+594
-52
No files matched your search
@@ -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;
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "../lib/types"
|
||||
import { ObservabilityContext, type TimeRangeFilter } from "./ObservabilityContextValue"
|
||||
import { getServiceName, formatAttributeValue, isSpanError } from "../lib/otel-utils"
|
||||
import { partitionMessages, mergeLogsByTraceId } from "../lib/ingest"
|
||||
import { addFifoSet, appendFifo, partitionMessages, mergeLogsByTraceId, STREAM_LIMITS } from "../lib/ingest"
|
||||
import { authFetch, tokens } from "../lib/auth"
|
||||
|
||||
interface ObservabilityProviderProps {
|
||||
@@ -257,21 +257,17 @@ export function ObservabilityProvider({
|
||||
if (queued.length > 0) {
|
||||
const partitioned = partitionMessages(queued)
|
||||
if (partitioned.traces.length) {
|
||||
setTraces((prev) => [...prev, ...partitioned.traces])
|
||||
setTraces((prev) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces))
|
||||
}
|
||||
if (partitioned.metrics.length) {
|
||||
setMetrics((prev) => [...prev, ...partitioned.metrics])
|
||||
setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics))
|
||||
}
|
||||
if (partitioned.logs.length) {
|
||||
setLogs((prev) => [...prev, ...partitioned.logs])
|
||||
setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs))
|
||||
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
|
||||
}
|
||||
if (partitioned.traceIds.size) {
|
||||
setKnownTraceIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
partitioned.traceIds.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
setKnownTraceIds((prev) => addFifoSet(prev, partitioned.traceIds, STREAM_LIMITS.knownTraceIds))
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
@@ -623,11 +619,11 @@ export function ObservabilityProvider({
|
||||
partitioned.metrics.length, "metrics",
|
||||
)
|
||||
|
||||
setTraces(partitioned.traces)
|
||||
setMetrics(partitioned.metrics)
|
||||
setLogs(partitioned.logs)
|
||||
setLogsByTraceId(partitioned.logsByTraceId)
|
||||
setKnownTraceIds(partitioned.traceIds)
|
||||
setTraces(partitioned.traces.slice(-STREAM_LIMITS.traces))
|
||||
setMetrics(partitioned.metrics.slice(-STREAM_LIMITS.metrics))
|
||||
setLogs(partitioned.logs.slice(-STREAM_LIMITS.logs))
|
||||
setLogsByTraceId(mergeLogsByTraceId({}, partitioned.logsByTraceId))
|
||||
setKnownTraceIds(addFifoSet(new Set(), partitioned.traceIds, STREAM_LIMITS.knownTraceIds))
|
||||
} catch (error) {
|
||||
console.error("Error fetching history:", error)
|
||||
}
|
||||
@@ -660,7 +656,11 @@ export function ObservabilityProvider({
|
||||
|
||||
// If stream is paused, buffer the incoming signal
|
||||
if (!isStreamingRef.current) {
|
||||
bufferedMessagesRef.current.push(data as OtelMessage)
|
||||
bufferedMessagesRef.current = appendFifo(
|
||||
bufferedMessagesRef.current,
|
||||
[data as OtelMessage],
|
||||
STREAM_LIMITS.bufferedMessages,
|
||||
)
|
||||
setBufferedCount(bufferedMessagesRef.current.length)
|
||||
return
|
||||
}
|
||||
@@ -668,21 +668,17 @@ export function ObservabilityProvider({
|
||||
const partitioned = partitionMessages([data as OtelMessage])
|
||||
|
||||
if (partitioned.traces.length) {
|
||||
setTraces((prev) => [...prev, ...partitioned.traces])
|
||||
setTraces((prev) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces))
|
||||
}
|
||||
if (partitioned.metrics.length) {
|
||||
setMetrics((prev) => [...prev, ...partitioned.metrics])
|
||||
setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics))
|
||||
}
|
||||
if (partitioned.logs.length) {
|
||||
setLogs((prev) => [...prev, ...partitioned.logs])
|
||||
setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs))
|
||||
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
|
||||
}
|
||||
if (partitioned.traceIds.size) {
|
||||
setKnownTraceIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
partitioned.traceIds.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
setKnownTraceIds((prev) => addFifoSet(prev, partitioned.traceIds, STREAM_LIMITS.knownTraceIds))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error)
|
||||
@@ -734,21 +730,22 @@ export function ObservabilityProvider({
|
||||
const partitioned = partitionMessages(data ?? [])
|
||||
|
||||
if (partitioned.traces.length) {
|
||||
setTraces((prev) => [...prev, ...partitioned.traces])
|
||||
setTraces((prev) => appendFifo(prev, partitioned.traces, STREAM_LIMITS.traces))
|
||||
}
|
||||
if (partitioned.metrics.length) {
|
||||
setMetrics((prev) => [...prev, ...partitioned.metrics])
|
||||
setMetrics((prev) => appendFifo(prev, partitioned.metrics, STREAM_LIMITS.metrics))
|
||||
}
|
||||
if (partitioned.logs.length) {
|
||||
setLogs((prev) => [...prev, ...partitioned.logs])
|
||||
setLogs((prev) => appendFifo(prev, partitioned.logs, STREAM_LIMITS.logs))
|
||||
setLogsByTraceId((prev) => mergeLogsByTraceId(prev, partitioned.logsByTraceId))
|
||||
}
|
||||
|
||||
setKnownTraceIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
partitioned.traceIds.forEach((id) => next.add(id))
|
||||
next.add(activeTraceId)
|
||||
return next
|
||||
return addFifoSet(
|
||||
prev,
|
||||
new Set([...partitioned.traceIds, activeTraceId]),
|
||||
STREAM_LIMITS.knownTraceIds,
|
||||
)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -5,6 +5,37 @@
|
||||
import type { LogEntry, MetricData, OtelMessage, TraceData } from "./types"
|
||||
import { normalizeLogEntry, normalizeMetricData, normalizeTrace } from "./normalize"
|
||||
|
||||
/**
|
||||
* Live telemetry is a rolling window. Without a bound, a long-lived browser
|
||||
* session eventually retains every span/log/metric it has ever received.
|
||||
*/
|
||||
export const STREAM_LIMITS = {
|
||||
traces: 500,
|
||||
logs: 1_000,
|
||||
metrics: 250,
|
||||
bufferedMessages: 2_000,
|
||||
knownTraceIds: 5_000,
|
||||
logsPerTrace: 100,
|
||||
logLinks: 10_000,
|
||||
} as const
|
||||
|
||||
/** Append items while retaining only the newest `limit` entries. */
|
||||
export function appendFifo<T>(previous: T[], additions: T[], limit: number): T[] {
|
||||
if (additions.length === 0) return previous
|
||||
const combined = previous.length + additions.length <= limit
|
||||
? [...previous, ...additions]
|
||||
: [...previous, ...additions].slice(-limit)
|
||||
return combined
|
||||
}
|
||||
|
||||
/** Add ids in arrival order while retaining only the newest ids. */
|
||||
export function addFifoSet<T>(previous: Set<T>, additions: Iterable<T>, limit: number): Set<T> {
|
||||
const next = new Set(previous)
|
||||
for (const value of additions) next.add(value)
|
||||
while (next.size > limit) next.delete(next.values().next().value as T)
|
||||
return next
|
||||
}
|
||||
|
||||
export interface PartitionedMessages {
|
||||
traces: TraceData[]
|
||||
metrics: MetricData[]
|
||||
@@ -75,11 +106,27 @@ export function partitionMessages(messages: OtelMessage[]): PartitionedMessages
|
||||
export function mergeLogsByTraceId(
|
||||
prev: Record<string, LogEntry[]>,
|
||||
additions: Record<string, LogEntry[]>,
|
||||
limit = STREAM_LIMITS.logsPerTrace,
|
||||
maxTraceIds = STREAM_LIMITS.knownTraceIds,
|
||||
maxTotalEntries = STREAM_LIMITS.logLinks,
|
||||
): Record<string, LogEntry[]> {
|
||||
if (Object.keys(additions).length === 0) return prev
|
||||
const next = { ...prev }
|
||||
Object.entries(additions).forEach(([traceId, entries]) => {
|
||||
next[traceId] = [...(next[traceId] ?? []), ...entries]
|
||||
next[traceId] = appendFifo(next[traceId] ?? [], entries, limit)
|
||||
})
|
||||
const traceIds = Object.keys(next)
|
||||
while (traceIds.length > maxTraceIds) {
|
||||
delete next[traceIds.shift()!]
|
||||
}
|
||||
let totalEntries = Object.values(next).reduce((sum, entries) => sum + entries.length, 0)
|
||||
while (totalEntries > maxTotalEntries) {
|
||||
const oldestTraceId = Object.keys(next)[0]
|
||||
if (!oldestTraceId) break
|
||||
const entries = next[oldestTraceId]
|
||||
entries.shift()
|
||||
totalEntries--
|
||||
if (entries.length === 0) delete next[oldestTraceId]
|
||||
}
|
||||
return next
|
||||
}
|
||||
Reference in new issue
Block a user