diff --git a/frontend/package.json b/frontend/package.json index b1b5d100226e..cbfe07d5a968 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "@sentry/react": "8.41.0", "@sentry/webpack-plugin": "2.22.6", "@signozhq/badge": "0.0.2", + "@signozhq/button": "0.0.2", "@signozhq/calendar": "0.0.0", "@signozhq/callout": "0.0.2", "@signozhq/design-tokens": "1.1.4", diff --git a/frontend/src/container/SpanList/SearchFilters.tsx b/frontend/src/container/SpanList/SearchFilters.tsx new file mode 100644 index 000000000000..23428e328027 --- /dev/null +++ b/frontend/src/container/SpanList/SearchFilters.tsx @@ -0,0 +1,19 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Input } from 'antd'; + +function SearchFilters(): JSX.Element { + return ( +
+
+ } + size="middle" + className="search-filters__search-input" + /> +
+
+ ); +} + +export default SearchFilters; diff --git a/frontend/src/container/SpanList/SpanList.styles.scss b/frontend/src/container/SpanList/SpanList.styles.scss new file mode 100644 index 000000000000..7791d4c18500 --- /dev/null +++ b/frontend/src/container/SpanList/SpanList.styles.scss @@ -0,0 +1,81 @@ +.span-list { + height: 100%; + display: flex; + flex-direction: column; + + &__header { + padding: 16px; + border-bottom: 1px solid var(--bg-slate-400); + // background: var(--bg-vanilla-100); + } + + &__content { + flex: 1; + overflow: hidden; + } +} + +.search-filters { + display: flex; + align-items: center; + gap: 16px; + + &__input { + flex: 1; + max-width: 400px; + } + + &__search-input { + .ant-input-prefix { + color: var(--text-slate-400); + } + } +} + +.span-table { + height: 100%; + + .span-name-with-expand { + display: flex; + align-items: center; + gap: 8px; + + .expand-button { + min-width: auto; + padding: 0; + height: auto; + border: none; + box-shadow: none; + display: flex; + + &:hover { + background: var(--bg-slate-100); + } + } + } + + .span-name { + padding-left: 24px; + } + + [data-slot='table-cell'] { + font-size: 13px; + } + thead > [data-slot='table-row'] { + background: var(--bg-slate-400); + [data-slot='table-head'] { + color: var(--bg-vanilla-400); + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 18px; /* 163.636% */ + letter-spacing: 0.44px; + text-transform: uppercase; + } + } + [data-slot='table-row'] { + &:hover { + background: var(--bg-slate-500); + } + } +} diff --git a/frontend/src/container/SpanList/SpanList.tsx b/frontend/src/container/SpanList/SpanList.tsx new file mode 100644 index 000000000000..f6a771e75a72 --- /dev/null +++ b/frontend/src/container/SpanList/SpanList.tsx @@ -0,0 +1,43 @@ +import './SpanList.styles.scss'; + +import { useMemo } from 'react'; +import { Span } from 'types/api/trace/getTraceV2'; + +import { mockEntrySpanData } from './mockData'; +import SearchFilters from './SearchFilters'; +import SpanTable from './SpanTable'; +import { transformEntrySpansToHierarchy } from './utils'; + +interface SpanListProps { + traceId?: string; + setSelectedSpan?: (span: Span) => void; +} + +function SpanList({ traceId, setSelectedSpan }: SpanListProps): JSX.Element { + const hierarchicalData = useMemo( + () => transformEntrySpansToHierarchy(mockEntrySpanData), + [], + ); + + return ( +
+
+ +
+
+ +
+
+ ); +} + +SpanList.defaultProps = { + traceId: undefined, + setSelectedSpan: (): void => {}, +}; + +export default SpanList; diff --git a/frontend/src/container/SpanList/SpanTable.tsx b/frontend/src/container/SpanList/SpanTable.tsx new file mode 100644 index 000000000000..98f0dde3a43d --- /dev/null +++ b/frontend/src/container/SpanList/SpanTable.tsx @@ -0,0 +1,367 @@ +import { Button } from '@signozhq/button'; +import { ColumnDef, DataTable, Row } from '@signozhq/table'; +import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { Span } from 'types/api/trace/getTraceV2'; + +import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types'; +import { fetchServiceSpans, formatDuration } from './utils'; + +// Constants +const SPAN_TYPE_ENTRY = 'entry-span'; +const SPAN_TYPE_SERVICE = 'service-span'; + +interface SpanTableProps { + data: HierarchicalSpanData; + traceId?: string; + setSelectedSpan?: (span: Span) => void; +} + +interface TableRowData { + id: string; + type: typeof SPAN_TYPE_ENTRY | typeof SPAN_TYPE_SERVICE; + spanName?: string; + serviceName?: string; + spanCount?: number; + duration?: string; + timestamp?: string; + statusCode?: string; + httpMethod?: string; + spanId?: string; + originalData?: ServiceEntrySpan | SpanDataRow; + isLoading?: boolean; +} + +function SpanTable({ + data, + traceId, + setSelectedSpan, +}: SpanTableProps): JSX.Element { + const [expandedEntrySpans, setExpandedEntrySpans] = useState< + Record + >({}); + const [loadingSpans, setLoadingSpans] = useState>({}); + + const handleEntrySpanClick = useCallback( + async (entrySpan: ServiceEntrySpan) => { + const spanId = entrySpan.spanData.data.span_id; + + if (expandedEntrySpans[spanId]) { + // Collapse - remove from expanded spans + const { [spanId]: removed, ...rest } = expandedEntrySpans; + setExpandedEntrySpans(rest); + return; + } + + // Expand - fetch service spans + if (!entrySpan.serviceSpans && traceId) { + setLoadingSpans((prev) => ({ ...prev, [spanId]: true })); + + try { + const serviceSpans = await fetchServiceSpans( + traceId, + entrySpan.serviceName, + ); + const updatedEntrySpan = { + ...entrySpan, + serviceSpans, + isExpanded: true, + }; + + setExpandedEntrySpans((prev) => ({ + ...prev, + [spanId]: updatedEntrySpan, + })); + } catch (error) { + console.error('Failed to fetch service spans:', error); + } finally { + setLoadingSpans((prev) => ({ ...prev, [spanId]: false })); + } + } else { + // Already have service spans, just toggle expansion + setExpandedEntrySpans((prev) => ({ + ...prev, + [spanId]: { ...entrySpan, isExpanded: true }, + })); + } + }, + [expandedEntrySpans, traceId], + ); + + const handleSpanClick = useCallback( + (span: SpanDataRow) => { + if (setSelectedSpan) { + // Convert span data to the format expected by SpanDetailsDrawer + const convertedSpan = ({ + id: span.data.span_id, + traceID: span.data.trace_id, + spanID: span.data.span_id, + parentSpanID: '', + operationName: span.data.name, + startTime: new Date(span.timestamp).getTime() * 1000000, // Convert to nanoseconds + duration: span.data.duration_nano, + tags: [], + logs: [], + process: { + serviceName: span.data['service.name'], + tags: [], + }, + } as unknown) as Span; + setSelectedSpan(convertedSpan); + } + }, + [setSelectedSpan], + ); + + const renderNameCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + if (original.type === SPAN_TYPE_ENTRY) { + const entrySpan = original.originalData as ServiceEntrySpan; + const spanId = entrySpan.spanData.data.span_id; + const isExpanded = !!expandedEntrySpans[spanId]; + const isLoading = loadingSpans[spanId]; + + return ( +
+
+ ); + } + // Service span (nested) + return
{original.spanName}
; + }, + [expandedEntrySpans, loadingSpans, handleEntrySpanClick], + ); + + const renderServiceCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + return {original.serviceName}; + }, + [], + ); + + const renderSpanCountCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + if (original.type === SPAN_TYPE_ENTRY && original.spanCount !== undefined) { + return {original.spanCount}; + } + return -; + }, + [], + ); + + const renderDurationCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + return {original.duration}; + }, + [], + ); + + const renderTimestampCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + return ( + + {new Date(original.timestamp || '').toLocaleString()} + + ); + }, + [], + ); + + const renderStatusCodeCell = useCallback( + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + return {original.statusCode}; + }, + [], + ); + + const renderHttpMethodCell = useCallback( + // eslint-disable-next-line react/no-unused-prop-types + ({ row }: { row: Row }): JSX.Element => { + const { original } = row; + return {original.httpMethod || '-'}; + }, + [], + ); + + const columns: ColumnDef[] = [ + { + id: 'name', + header: 'Span Name', + accessorKey: 'spanName', + cell: renderNameCell, + }, + { + id: 'timestamp', + header: 'Timestamp', + accessorKey: 'timestamp', + size: 180, + cell: renderTimestampCell, + }, + { + id: 'httpMethod', + header: 'Method', + accessorKey: 'httpMethod', + size: 80, + cell: renderHttpMethodCell, + }, + { + id: 'statusCode', + header: 'Status', + accessorKey: 'statusCode', + size: 80, + cell: renderStatusCodeCell, + }, + { + id: 'service', + header: 'Service', + accessorKey: 'serviceName', + size: 120, + cell: renderServiceCell, + }, + { + id: 'spans', + header: 'Spans', + accessorKey: 'spanCount', + size: 80, + cell: renderSpanCountCell, + }, + { + id: 'duration', + header: 'Duration', + accessorKey: 'duration', + size: 120, + cell: renderDurationCell, + }, + ]; + + const flattenedData = useMemo(() => { + const result: TableRowData[] = []; + + data.entrySpans.forEach((entrySpan) => { + const spanId = entrySpan.spanData.data.span_id; + + // Calculate span count for this service + const expandedSpan = expandedEntrySpans[spanId]; + const spanCount = expandedSpan?.serviceSpans?.length || 0; + + // Add entry span row + result.push({ + id: spanId, + type: SPAN_TYPE_ENTRY, + spanName: entrySpan.spanData.data.name, + serviceName: entrySpan.serviceName, + spanCount: spanCount > 0 ? spanCount : undefined, + duration: formatDuration(entrySpan.spanData.data.duration_nano), + timestamp: entrySpan.spanData.timestamp, + statusCode: entrySpan.spanData.data.response_status_code, + httpMethod: entrySpan.spanData.data.http_method, + spanId, + originalData: entrySpan, + }); + + // Add service spans if expanded + if (expandedSpan?.serviceSpans) { + expandedSpan.serviceSpans.forEach((serviceSpan) => { + result.push({ + id: serviceSpan.data.span_id, + type: SPAN_TYPE_SERVICE, + spanName: serviceSpan.data.name, + serviceName: serviceSpan.data['service.name'], + duration: formatDuration(serviceSpan.data.duration_nano), + timestamp: serviceSpan.timestamp, + statusCode: serviceSpan.data.response_status_code, + httpMethod: serviceSpan.data.http_method, + spanId: serviceSpan.data.span_id, + originalData: serviceSpan, + }); + }); + } + }); + + return result; + }, [data.entrySpans, expandedEntrySpans]); + + const handleRowClick = useCallback( + (row: Row) => { + const { original } = row; + if (original.type === SPAN_TYPE_ENTRY) { + handleEntrySpanClick(original.originalData as ServiceEntrySpan); + } else if (original.type === SPAN_TYPE_SERVICE) { + handleSpanClick(original.originalData as SpanDataRow); + } + }, + [handleEntrySpanClick, handleSpanClick], + ); + + const args = { + columns, + tableId: 'span-list-table', + enableSorting: false, + enableFiltering: false, + enableGlobalFilter: false, + enableColumnReordering: false, + enableColumnResizing: false, + enableColumnPinning: false, + enableRowSelection: false, + enablePagination: false, + showHeaders: true, + defaultColumnWidth: 150, + minColumnWidth: 80, + maxColumnWidth: 300, + enableVirtualization: false, + fixedHeight: 600, + }; + + return ( +
+ +
+ ); +} + +SpanTable.defaultProps = { + traceId: undefined, + setSelectedSpan: undefined, +}; + +export default SpanTable; diff --git a/frontend/src/container/SpanList/mockData.ts b/frontend/src/container/SpanList/mockData.ts new file mode 100644 index 000000000000..4fb4dbf96ef0 --- /dev/null +++ b/frontend/src/container/SpanList/mockData.ts @@ -0,0 +1,96 @@ +import { SpanDataRow } from './types'; + +export const mockEntrySpanData: SpanDataRow[] = [ + { + data: { + duration_nano: 3878813, + http_method: '', + // eslint-disable-next-line sonarjs/no-duplicate-string + name: 'CurrencyService/Convert', + response_status_code: '0', + 'service.name': 'currencyservice', + span_id: '8439d7461ab457a2', + timestamp: '2025-09-03T11:33:46.060209146Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:46.060209146Z', + }, + { + data: { + duration_nano: 12485759, + http_method: '', + name: 'CurrencyService/Convert', + response_status_code: '0', + 'service.name': 'currencyservice', + span_id: '7dbf7811106b1049', + timestamp: '2025-09-03T11:33:46.058715482Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:46.058715482Z', + }, + { + data: { + duration_nano: 6950595, + http_method: '', + name: 'CurrencyService/Convert', + response_status_code: '0', + 'service.name': 'currencyservice', + span_id: '9adcdf1baa56d23a', + timestamp: '2025-09-03T11:33:46.058319328Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:46.058319328Z', + }, + { + data: { + duration_nano: 5323696, + http_method: '', + name: 'CurrencyService/Convert', + response_status_code: '0', + 'service.name': 'currencyservice', + span_id: '1a1b01a750dfe4d6', + timestamp: '2025-09-03T11:33:46.057323233Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:46.057323233Z', + }, + { + data: { + duration_nano: 133659, + http_method: '', + name: 'oteldemo.ProductCatalogService/GetProduct', + response_status_code: '0', + 'service.name': 'productcatalogservice', + span_id: '485a987e9dbf8ddd', + timestamp: '2025-09-03T11:33:45.963838319Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:45.963838319Z', + }, + { + data: { + duration_nano: 245000000, + http_method: 'GET', + name: '/api/checkout', + response_status_code: '200', + 'service.name': 'checkoutservice', + span_id: 'abc123def456', + timestamp: '2025-09-03T11:33:45.800000000Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:45.800000000Z', + }, + { + data: { + duration_nano: 150000000, + http_method: 'POST', + name: '/api/payment', + response_status_code: '200', + 'service.name': 'paymentservice', + span_id: 'def456ghi789', + timestamp: '2025-09-03T11:33:45.750000000Z', + trace_id: '5ad9c01671f0e38582efe03bbf81360a', + }, + timestamp: '2025-09-03T11:33:45.750000000Z', + }, +]; diff --git a/frontend/src/container/SpanList/types.ts b/frontend/src/container/SpanList/types.ts new file mode 100644 index 000000000000..7890f425028e --- /dev/null +++ b/frontend/src/container/SpanList/types.ts @@ -0,0 +1,47 @@ +export interface SpanData { + duration_nano: number; + http_method: string; + name: string; + response_status_code: string; + 'service.name': string; + span_id: string; + timestamp: string; + trace_id: string; +} + +export interface SpanDataRow { + data: SpanData; + timestamp: string; +} + +export interface ApiResponse { + status: string; + data: { + type: string; + meta: { + rowsScanned: number; + bytesScanned: number; + durationMs: number; + }; + data: { + results: Array<{ + queryName: string; + nextCursor: string; + rows: SpanDataRow[]; + }>; + }; + }; +} + +export interface ServiceEntrySpan { + spanData: SpanDataRow; + serviceName: string; + isExpanded?: boolean; + serviceSpans?: SpanDataRow[]; // All spans for this service when expanded + isLoadingServiceSpans?: boolean; +} + +export interface HierarchicalSpanData { + entrySpans: ServiceEntrySpan[]; + totalTraceTime: number; +} diff --git a/frontend/src/container/SpanList/utils.ts b/frontend/src/container/SpanList/utils.ts new file mode 100644 index 000000000000..402c6f2975d8 --- /dev/null +++ b/frontend/src/container/SpanList/utils.ts @@ -0,0 +1,89 @@ +import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types'; + +export function formatDuration(durationNano: number): string { + if (durationNano < 1000) { + return `${durationNano}ns`; + } + if (durationNano < 1000000) { + return `${(durationNano / 1000).toFixed(2)}μs`; + } + if (durationNano < 1000000000) { + return `${(durationNano / 1000000).toFixed(2)}ms`; + } + return `${(durationNano / 1000000000).toFixed(2)}s`; +} + +export function transformEntrySpansToHierarchy( + entrySpans: SpanDataRow[], +): HierarchicalSpanData { + let totalTraceTime = 0; + + // Calculate total trace time from all entry spans + entrySpans.forEach((span) => { + totalTraceTime += span.data.duration_nano; + }); + + // Transform entry spans to ServiceEntrySpan structure + const entrySpansList: ServiceEntrySpan[] = entrySpans.map((span) => ({ + spanData: span, + serviceName: span.data['service.name'], + isExpanded: false, + serviceSpans: undefined, + isLoadingServiceSpans: false, + })); + + // Sort by timestamp (most recent first) + entrySpansList.sort( + (a, b) => + new Date(b.spanData.timestamp).getTime() - + new Date(a.spanData.timestamp).getTime(), + ); + + return { + entrySpans: entrySpansList, + totalTraceTime, + }; +} + +// Mock function to simulate fetching service spans +export function fetchServiceSpans( + traceId: string, + serviceName: string, +): Promise { + // This would normally make an API call to get spans for the specific service + // For now, return mock data filtered by service name + return new Promise((resolve) => { + setTimeout(() => { + // Mock response - in real implementation, this would call the API + const mockServiceSpans: SpanDataRow[] = [ + { + data: { + duration_nano: 1500000, + http_method: 'GET', + name: `${serviceName}/internal-call-1`, + response_status_code: '200', + 'service.name': serviceName, + span_id: `${serviceName}-span-1`, + timestamp: '2025-09-03T11:33:46.100000000Z', + trace_id: traceId, + }, + timestamp: '2025-09-03T11:33:46.100000000Z', + }, + { + data: { + duration_nano: 2500000, + http_method: 'POST', + name: `${serviceName}/internal-call-2`, + response_status_code: '200', + 'service.name': serviceName, + span_id: `${serviceName}-span-2`, + timestamp: '2025-09-03T11:33:46.200000000Z', + trace_id: traceId, + }, + timestamp: '2025-09-03T11:33:46.200000000Z', + }, + ]; + resolve(mockServiceSpans); + }, 500); // Simulate network delay + }); +} diff --git a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx index c672e2264997..080130ebe41e 100644 --- a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx +++ b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx @@ -1,9 +1,11 @@ import './TraceDetailV2.styles.scss'; +import { UnorderedListOutlined } from '@ant-design/icons'; import { Button, Tabs } from 'antd'; import FlamegraphImg from 'assets/TraceDetail/Flamegraph'; import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph'; import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer'; +import SpanList from 'container/SpanList/SpanList'; import TraceMetadata from 'container/TraceMetadata/TraceMetadata'; import TraceWaterfall, { IInterestedSpan, @@ -118,6 +120,19 @@ function TraceDetailsV2(): JSX.Element { ), }, + { + label: ( + + ), + key: 'span-list', + children: , + }, ]; return ( diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 99360b670401..6c8ab0ee8ede 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4247,7 +4247,7 @@ tailwind-merge "^2.5.2" tailwindcss-animate "^1.0.7" -"@signozhq/button@^0.0.2": +"@signozhq/button@0.0.2", "@signozhq/button@^0.0.2": version "0.0.2" resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f" integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==