Compare commits

...

21 Commits

Author SHA1 Message Date
ahmadshaheer
7dc701c988 Merge remote-tracking branch 'origin' into feat/span-list-tab-in-trace-details-v2 2025-09-21 20:32:36 +04:30
ahmadshaheer
b80db44c67 chore: fix the light mode styles 2025-09-21 20:26:24 +04:30
ahmadshaheer
0440f8be7c fix: move the pagination to the right side 2025-09-21 20:19:37 +04:30
ahmadshaheer
9533fe1c76 fix: fix the alignment of next and prev pagination buttons 2025-09-21 20:13:13 +04:30
ahmadshaheer
c8f3ab667a fix: disable next page in child spans pagination if nextCursor is empty 2025-09-21 20:07:05 +04:30
ahmadshaheer
47e2687afe fix: re-order columns + fix overflowing value + overall improvements 2025-09-21 20:01:07 +04:30
ahmadshaheer
277d6c5dbd fix: on clicking child span, fetch details from API + overall bugfixes 2025-09-21 19:24:36 +04:30
ahmadshaheer
ae18c138c8 fix: improve the span list table ui 2025-09-19 05:55:21 +04:30
ahmadshaheer
5a1f20f626 fix: add padding left to the nested rows 2025-09-18 19:43:26 +04:30
ahmadshaheer
ae5895ca49 fix: disable next page if next pages are not available 2025-09-18 17:23:40 +04:30
ahmadshaheer
d1b4c8cae8 fix: send start time - 30 minutes and end time + 90 minutes with span list request 2025-09-18 17:06:32 +04:30
ahmadshaheer
474fd623fc fix: upgrade @signozhq/table to fix the dark mode issues 2025-09-18 16:08:35 +04:30
ahmadshaheer
777af5dc4f fix: add deduplication logic 2025-09-18 15:26:15 +04:30
ahmadshaheer
d432ee463d fix: add next/prev pagination 2025-09-17 12:02:09 +04:30
ahmadshaheer
5e1ba0ed30 feat: add support for filtering the span list 2025-09-15 21:20:44 +04:30
ahmadshaheer
6776ecf601 feat: add support for service spans pagination 2025-09-15 13:18:18 +04:30
ahmadshaheer
101976914c feat: add support for span list pagination 2025-09-15 10:41:54 +04:30
ahmadshaheer
2b3c309c85 chore: re-order columns 2025-09-14 20:26:28 +04:30
ahmadshaheer
0976a572e3 fix: get span list data from API 2025-09-14 20:18:49 +04:30
ahmadshaheer
3d4c6eda71 fix: add span list to query params and dock sidebar when in span list view 2025-09-14 17:42:19 +04:30
ahmadshaheer
da841650f5 feat: span list basic UI using static data 2025-09-14 17:41:20 +04:30
14 changed files with 1275 additions and 20 deletions

View File

@@ -52,7 +52,7 @@
"@signozhq/popover": "0.0.0",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",
"@signozhq/table": "0.4.0",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",
"@tanstack/react-virtual": "3.11.2",

View File

@@ -264,6 +264,7 @@ function convertRawData(
date: row.timestamp,
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -1,11 +1,11 @@
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Select } from 'antd';
import { Select } from 'antd';
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
import { memo, useMemo } from 'react';
import { popupContainer } from 'utils/selectPopupContainer';
import { defaultSelectStyle } from './config';
import { Container } from './styles';
import { Container, StyledButton } from './styles';
function Controls({
offset = 0,
@@ -18,6 +18,7 @@ function Controls({
handleCountItemsPerPageChange,
isLogPanel = false,
showSizeChanger = true,
nextCursor,
}: ControlsProps): JSX.Element | null {
const isNextAndPreviousDisabled = useMemo(
() => isLoading || countPerPage < 0 || totalCount === 0,
@@ -27,15 +28,28 @@ function Controls({
() => (isLogPanel ? false : offset <= 0 || isNextAndPreviousDisabled),
[isLogPanel, isNextAndPreviousDisabled, offset],
);
const isNextDisabled = useMemo(
() =>
isLogPanel ? false : totalCount < countPerPage || isNextAndPreviousDisabled,
[countPerPage, isLogPanel, isNextAndPreviousDisabled, totalCount],
);
const isNextDisabled = useMemo(() => {
if (isLogPanel) return false;
if (isNextAndPreviousDisabled) return true;
// If nextCursor is provided, use it to determine if there are more pages
if (nextCursor !== undefined) {
return nextCursor === '' || nextCursor === null;
}
// Fallback to original logic for components that don't provide nextCursor
return totalCount < countPerPage;
}, [
countPerPage,
isLogPanel,
isNextAndPreviousDisabled,
totalCount,
nextCursor,
]);
return (
<Container>
<Button
<StyledButton
loading={isLoading}
size="small"
type="link"
@@ -43,8 +57,8 @@ function Controls({
onClick={handleNavigatePrevious}
>
<LeftOutlined /> Previous
</Button>
<Button
</StyledButton>
<StyledButton
loading={isLoading}
size="small"
type="link"
@@ -52,7 +66,7 @@ function Controls({
onClick={handleNavigateNext}
>
Next <RightOutlined />
</Button>
</StyledButton>
{showSizeChanger && (
<Select<Pagination['limit']>
@@ -79,6 +93,7 @@ Controls.defaultProps = {
perPageOptions: DEFAULT_PER_PAGE_OPTIONS,
isLogPanel: false,
showSizeChanger: true,
nextCursor: undefined,
};
export interface ControlsProps {
@@ -92,6 +107,7 @@ export interface ControlsProps {
handleCountItemsPerPageChange: (value: Pagination['limit']) => void;
isLogPanel?: boolean;
showSizeChanger?: boolean;
nextCursor?: string;
}
export default memo(Controls);

View File

@@ -1,3 +1,4 @@
import { Button } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
@@ -5,3 +6,8 @@ export const Container = styled.div`
align-items: center;
gap: 0.5rem;
`;
export const StyledButton = styled(Button)`
display: flex;
align-items: center;
`;

View File

@@ -0,0 +1,19 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input } from 'antd';
function SearchFilters(): JSX.Element {
return (
<div className="search-filters">
<div className="search-filters__input">
<Input
placeholder="Search Spans..."
prefix={<SearchOutlined />}
size="middle"
className="search-filters__search-input"
/>
</div>
</div>
);
}
export default SearchFilters;

View File

@@ -0,0 +1,146 @@
.span-list {
height: 100%;
display: flex;
flex-direction: column;
&__header {
padding: 16px;
border-bottom: 1px solid var(--bg-slate-400);
}
&__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%;
margin-top: 16px;
.data-table-container {
border: none;
}
.sticky-header-table-container {
width: 100%;
}
[data-slot='table-cell'] {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.service-cell-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);
}
}
}
.service-cell-child {
padding-left: 32px;
}
.span-table-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.entry-pagination {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.service-span-pagination {
display: flex;
justify-content: flex-end;
padding-left: 32px;
}
[data-slot='table-header'] {
&,
[data-slot='table-row']:hover {
background: transparent;
}
}
thead > [data-slot='table-row'] {
border-bottom: none !important;
[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'] {
border: none !important;
&:nth-of-type(odd) {
background: rgba(171, 189, 255, 0.01);
}
&:hover {
background: var(--bg-slate-500);
}
}
[data-slot='table-head'] {
background: var(--bg-ink-500);
}
[data-slot='table-head']:first-of-type {
background: #13141a;
}
[data-slot='table-cell']:first-of-type {
background: rgba(171, 189, 255, 0.04);
}
}
.lightMode {
.span-table {
[data-slot='table-row'] {
&:hover {
background: var(--bg-vanilla-400);
}
}
[data-slot='table-head'] {
background: var(--bg-vanilla-100);
}
[data-slot='table-head']:first-of-type {
background: var(--bg-vanilla-200);
}
}
}

View File

@@ -0,0 +1,198 @@
import './SpanList.styles.scss';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback, useMemo } from 'react';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { DataSource } from 'types/common/queryBuilder';
import SpanTable from './SpanTable';
import { SpanDataRow } from './types';
import { transformEntrySpansToHierarchy } from './utils';
interface SpanListProps {
traceId?: string;
setSelectedSpan?: (span: Span) => void;
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| ErrorResponse
| undefined;
}
function SpanList({
traceId,
setSelectedSpan,
traceData,
}: SpanListProps): JSX.Element {
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const payload = initialQueriesMap.traces;
// Entry spans pagination
const entryPage = parseInt(urlQuery.get('entryPage') || '1', 10);
const entryPageSize = 10;
const startTimestamp = useMemo(
() =>
traceData?.payload?.startTimestampMillis != null
? traceData.payload.startTimestampMillis / 1e3 - 30 * 60 * 1000
: undefined,
[traceData?.payload?.startTimestampMillis],
);
const endTimestamp = useMemo(
() =>
traceData?.payload?.endTimestampMillis != null
? traceData.payload.endTimestampMillis / 1e3 + 90 * 60 * 1000
: undefined,
[traceData?.payload?.endTimestampMillis],
);
const { data, isLoading, isFetching } = useGetQueryRange(
{
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
...(startTimestamp != null ? { start: startTimestamp } : {}),
...(endTimestamp != null ? { end: endTimestamp } : {}),
query: {
...payload,
builder: {
...payload.builder,
queryData: [
{
...payload.builder.queryData[0],
...{
queryName: 'A',
dataSource: DataSource.TRACES,
stepInterval: null,
disabled: false,
filter: {
expression: `trace_id = '${traceId}' isEntryPoint = 'true'`,
},
aggregateOperator: 'count',
timeAggregation: 'rate',
spaceAggregation: 'sum',
aggregations: [
{
expression: 'count() ',
},
],
expression: 'A',
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
having: {
expression: '',
},
reduceTo: 'avg',
},
},
],
},
},
tableParams: {
pagination: {
limit: entryPageSize,
offset: (entryPage - 1) * entryPageSize,
},
selectColumns: [
{
name: 'service.name',
fieldDataType: 'string',
signal: 'traces',
fieldContext: 'resource',
},
{
name: 'name',
fieldDataType: 'string',
signal: 'traces',
},
{
name: 'duration_nano',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
],
},
},
// version,
ENTITY_VERSION_V5,
);
const handleEntryPageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams(window.location.search);
params.set('entryPage', newPage.toString());
safeNavigate({ search: params.toString() });
},
[safeNavigate],
);
const hierarchicalData = useMemo(
() =>
// TODO(shaheer): properly fix the type
transformEntrySpansToHierarchy(
(data?.payload.data.newResult.data.result[0].list as unknown) as
| SpanDataRow[]
| undefined,
),
[data?.payload.data.newResult.data.result],
);
// Extract nextCursor from the API response
const nextCursor = useMemo(
() => data?.payload?.data?.newResult?.data?.result?.[0]?.nextCursor || '',
[data?.payload?.data?.newResult?.data?.result],
);
console.log(data);
return (
<div className="span-list">
<div className="span-list__content">
<SpanTable
data={hierarchicalData}
isLoading={isLoading || isFetching}
traceId={traceId}
setSelectedSpan={setSelectedSpan}
entryPagination={{
currentPage: entryPage,
totalCount: entryPageSize,
pageSize: entryPageSize,
onPageChange: handleEntryPageChange,
isLoading: isLoading || isFetching,
nextCursor,
}}
/>
</div>
</div>
);
}
SpanList.defaultProps = {
traceId: undefined,
setSelectedSpan: (): void => {},
};
export default SpanList;

View File

@@ -0,0 +1,614 @@
import { Button } from '@signozhq/button';
import { ColumnDef, DataTable, Row } from '@signozhq/table';
import getTraceV2 from 'api/trace/getTraceV2';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import Controls from 'container/Controls';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useCallback, useMemo, useState } from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
import { fetchServiceSpans } from './utils';
interface EntryPagination {
currentPage: number;
totalCount: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
isLoading?: boolean;
nextCursor?: string;
}
// Constants
const SPAN_TYPE_ENTRY = 'entry-span';
const SPAN_TYPE_SERVICE = 'service-span';
const SPAN_TYPE_PAGINATION = 'pagination-row';
const SERVICE_SPAN_PAGE_SIZE = 10;
interface SpanTableProps {
data: HierarchicalSpanData;
traceId?: string;
setSelectedSpan?: (span: Span) => void;
isLoading?: boolean;
entryPagination: EntryPagination;
}
interface TableRowData {
id: string;
type:
| typeof SPAN_TYPE_ENTRY
| typeof SPAN_TYPE_SERVICE
| typeof SPAN_TYPE_PAGINATION;
spanName?: string;
serviceName?: string;
spanCount?: number;
duration?: string;
timestamp?: string;
statusCode?: string;
httpMethod?: string;
spanId?: string;
originalData?: ServiceEntrySpan | SpanDataRow;
isLoading?: boolean;
entrySpanId?: string; // For pagination rows
}
function SpanTable({
data,
traceId,
setSelectedSpan,
isLoading,
entryPagination,
}: SpanTableProps): JSX.Element {
const [expandedEntrySpans, setExpandedEntrySpans] = useState<
Record<string, ServiceEntrySpan>
>({});
const [loadingSpans, setLoadingSpans] = useState<Record<string, boolean>>({});
const [serviceSpansPagination, setServiceSpansPagination] = useState<
Record<
string,
{
currentPage: number;
totalCount?: number;
hasMorePages?: boolean;
nextCursor?: string;
}
>
>({});
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 }));
// Initialize pagination state for this entry span
setServiceSpansPagination((prev) => ({
...prev,
[spanId]: { currentPage: 1, hasMorePages: false },
}));
try {
const currentPage = 1;
const offset = (currentPage - 1) * SERVICE_SPAN_PAGE_SIZE;
const { spans: serviceSpans, nextCursor } = await fetchServiceSpans(
traceId,
entrySpan.serviceName,
SERVICE_SPAN_PAGE_SIZE,
offset,
);
const updatedEntrySpan = {
...entrySpan,
serviceSpans,
isExpanded: true,
};
// Update pagination with nextCursor
setServiceSpansPagination((prevPagination) => ({
...prevPagination,
[spanId]: {
...prevPagination[spanId],
hasMorePages: serviceSpans.length === SERVICE_SPAN_PAGE_SIZE,
nextCursor,
},
}));
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 handleServiceSpanPageChange = useCallback(
async (entrySpanId: string, newPage: number) => {
if (!traceId) return;
const entrySpan = expandedEntrySpans[entrySpanId];
if (!entrySpan) return;
setLoadingSpans((prev) => ({ ...prev, [entrySpanId]: true }));
try {
const offset = (newPage - 1) * SERVICE_SPAN_PAGE_SIZE;
const { spans: serviceSpans, nextCursor } = await fetchServiceSpans(
traceId,
entrySpan.serviceName,
SERVICE_SPAN_PAGE_SIZE,
offset,
);
// Update pagination with nextCursor
setServiceSpansPagination((prevPagination) => ({
...prevPagination,
[entrySpanId]: {
...prevPagination[entrySpanId],
currentPage: newPage,
hasMorePages: serviceSpans.length === SERVICE_SPAN_PAGE_SIZE,
nextCursor,
},
}));
// Update the expanded entry span with new service spans
setExpandedEntrySpans((prev) => ({
...prev,
[entrySpanId]: {
...entrySpan,
serviceSpans,
isExpanded: true,
},
}));
} catch (error) {
console.error('Failed to fetch service spans for page:', error);
} finally {
setLoadingSpans((prev) => ({ ...prev, [entrySpanId]: false }));
}
},
[expandedEntrySpans, traceId],
);
const handleServiceSpanNavigatePrevious = useCallback(
(entrySpanId: string) => {
const pagination = serviceSpansPagination[entrySpanId];
if (pagination && pagination.currentPage > 1) {
handleServiceSpanPageChange(entrySpanId, pagination.currentPage - 1);
}
},
[serviceSpansPagination, handleServiceSpanPageChange],
);
const handleServiceSpanNavigateNext = useCallback(
(entrySpanId: string) => {
const pagination = serviceSpansPagination[entrySpanId];
if (pagination && pagination.hasMorePages) {
handleServiceSpanPageChange(entrySpanId, pagination.currentPage + 1);
}
},
[serviceSpansPagination, handleServiceSpanPageChange],
);
const handleSpanClick = useCallback(
async (span: SpanDataRow): Promise<void> => {
if (!setSelectedSpan || !traceId) return;
try {
// Make API call to fetch full span details
const response = await getTraceV2({
traceId,
selectedSpanId: span.data.span_id,
uncollapsedSpans: [],
isSelectedSpanIDUnCollapsed: true,
});
if (response.payload?.spans) {
const fullSpan = response.payload.spans.find(
(s: Span) => s.spanId === span.data.span_id,
);
console.log({ fullSpan });
if (fullSpan) {
setSelectedSpan(fullSpan);
}
}
} catch (error) {
console.error('Failed to fetch span details:', error);
}
},
[setSelectedSpan, traceId],
);
const renderNameCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element => {
const { original } = row;
if (original.type === SPAN_TYPE_ENTRY) {
return (
<LineClampedText
text={original.spanName || ''}
lines={1}
tooltipProps={{ placement: 'topLeft' }}
/>
);
}
if (original.type === SPAN_TYPE_PAGINATION) return <div />;
// Service span (nested)
return (
<LineClampedText
text={original.spanName || ''}
lines={1}
tooltipProps={{ placement: 'topLeft' }}
/>
);
},
[],
);
const renderServiceCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) return null;
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 (
<div className="service-cell-with-expand">
<Button
variant="ghost"
size="sm"
loading={isLoading}
prefixIcon={
!isLoading && isExpanded ? (
<ChevronDownIcon size={16} />
) : (
<ChevronRightIcon size={16} />
)
}
onClick={(e): void => {
e.stopPropagation();
handleEntrySpanClick(entrySpan);
}}
className="expand-button"
/>
<LineClampedText
text={original.serviceName || ''}
lines={1}
tooltipProps={{ placement: 'topLeft' }}
/>
</div>
);
}
return (
<div className="service-cell-child">
<LineClampedText
text={original.serviceName || ''}
lines={1}
tooltipProps={{ placement: 'topLeft' }}
/>
</div>
);
},
[expandedEntrySpans, loadingSpans, handleEntrySpanClick],
);
const renderDurationCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) return null;
return <span>{original.duration}</span>;
},
[],
);
const renderTimestampCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) {
const { entrySpanId } = original;
if (!entrySpanId) return <div />;
const entrySpan = expandedEntrySpans[entrySpanId];
const pagination = serviceSpansPagination[entrySpanId];
if (!entrySpan || !pagination) return <div />;
return (
<div className="service-span-pagination">
<Controls
offset={(pagination.currentPage - 1) * SERVICE_SPAN_PAGE_SIZE}
totalCount={SERVICE_SPAN_PAGE_SIZE * 10}
countPerPage={SERVICE_SPAN_PAGE_SIZE}
isLoading={loadingSpans[entrySpanId] || false}
handleNavigatePrevious={(): void =>
handleServiceSpanNavigatePrevious(entrySpanId)
}
handleNavigateNext={(): void =>
handleServiceSpanNavigateNext(entrySpanId)
}
handleCountItemsPerPageChange={(): void => {}} // Service spans use fixed page size
showSizeChanger={false} // Disable page size changer for service spans
nextCursor={pagination.nextCursor}
/>
</div>
);
}
return (
<span className="timestamp">
{new Date(original.timestamp || '').toLocaleString()}
</span>
);
},
[
expandedEntrySpans,
loadingSpans,
serviceSpansPagination,
handleServiceSpanNavigatePrevious,
handleServiceSpanNavigateNext,
],
);
const renderStatusCodeCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) return null;
return <span>{original.statusCode || '-'}</span>;
},
[],
);
const renderSpanIdCell = useCallback(
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) return null;
return <span className="span-id">{original.spanId}</span>;
},
[],
);
const renderHttpMethodCell = useCallback(
// eslint-disable-next-line react/no-unused-prop-types
({ row }: { row: Row<TableRowData> }): JSX.Element | null => {
const { original } = row;
if (original.type === SPAN_TYPE_PAGINATION) return null;
return <span>{original.httpMethod || '-'}</span>;
},
[],
);
const columns: ColumnDef<TableRowData>[] = [
{
id: 'service',
header: 'Service',
accessorKey: 'serviceName',
size: 120,
cell: renderServiceCell,
},
{
id: 'spanId',
header: 'Span ID',
accessorKey: 'spanId',
size: 150,
cell: renderSpanIdCell,
},
{
id: 'name',
header: 'Span Name',
accessorKey: 'spanName',
cell: renderNameCell,
},
{
id: 'httpMethod',
header: 'Method',
accessorKey: 'httpMethod',
size: 80,
cell: renderHttpMethodCell,
},
{
id: 'statusCode',
header: 'Status',
accessorKey: 'statusCode',
size: 80,
cell: renderStatusCodeCell,
},
{
id: 'duration',
header: 'Duration',
accessorKey: 'duration',
size: 120,
cell: renderDurationCell,
},
{
id: 'timestamp',
header: 'Timestamp',
accessorKey: 'timestamp',
size: 180,
cell: renderTimestampCell,
},
];
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: getYAxisFormattedValue(
entrySpan.spanData.data.duration_nano.toString(),
'ns',
),
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: getYAxisFormattedValue(
serviceSpan.data.duration_nano.toString(),
'ns',
),
timestamp: serviceSpan.timestamp,
statusCode: serviceSpan.data.response_status_code,
httpMethod: serviceSpan.data.http_method,
spanId: serviceSpan.data.span_id,
originalData: serviceSpan,
});
});
// Add pagination row for service spans if we have spans and (current page > 1 or more pages available)
const pagination = serviceSpansPagination[spanId];
if (
pagination &&
expandedSpan.serviceSpans.length > 0 &&
(pagination.currentPage > 1 || pagination.hasMorePages)
) {
result.push({
id: `${spanId}-pagination`,
type: SPAN_TYPE_PAGINATION,
entrySpanId: spanId,
});
}
}
});
return result;
}, [data.entrySpans, expandedEntrySpans, serviceSpansPagination]);
const handleRowClick = useCallback(
(row: Row<TableRowData>) => {
const { original } = row;
if (original.type === SPAN_TYPE_ENTRY) {
// For entry spans, expand/collapse
const entrySpan = original.originalData as ServiceEntrySpan;
handleEntrySpanClick(entrySpan);
} else if (original.type === SPAN_TYPE_SERVICE) {
// For service spans, trigger API call to fetch full details
handleSpanClick(original.originalData as SpanDataRow);
}
},
[handleEntrySpanClick, handleSpanClick],
);
const args = {
columns,
tableId: 'span-list-table',
enableSorting: false,
enableFiltering: false,
enableGlobalFilter: false,
enableColumnReordering: false,
enableColumnResizing: true,
enableColumnPinning: false,
enableRowSelection: false,
enablePagination: false,
showHeaders: true,
defaultColumnWidth: 150,
minColumnWidth: 80,
maxColumnWidth: 300,
enableVirtualization: false,
fixedHeight: 600,
};
const handleNavigatePrevious = useCallback(() => {
if (entryPagination.currentPage > 1) {
entryPagination.onPageChange(entryPagination.currentPage - 1);
}
}, [entryPagination]);
const handleNavigateNext = useCallback(() => {
entryPagination.onPageChange(entryPagination.currentPage + 1);
}, [entryPagination]);
return (
<div className="span-table">
<div className="span-table-content">
<DataTable
columns={columns}
tableId={args.tableId}
enableSorting={args.enableSorting}
enableFiltering={args.enableFiltering}
enableGlobalFilter={args.enableGlobalFilter}
enableColumnReordering={args.enableColumnReordering}
enableColumnResizing={args.enableColumnResizing}
enableColumnPinning={args.enableColumnPinning}
enableRowSelection={args.enableRowSelection}
enablePagination={args.enablePagination}
showHeaders={args.showHeaders}
defaultColumnWidth={args.defaultColumnWidth}
minColumnWidth={args.minColumnWidth}
maxColumnWidth={args.maxColumnWidth}
enableVirtualization={args.enableVirtualization}
fixedHeight={args.fixedHeight}
data={flattenedData}
onRowClick={handleRowClick}
isLoading={isLoading}
/>
<div className="entry-pagination">
<Controls
offset={(entryPagination.currentPage - 1) * entryPagination.pageSize}
countPerPage={entryPagination.pageSize}
totalCount={flattenedData.length}
isLoading={entryPagination.isLoading || isLoading || false}
handleNavigatePrevious={handleNavigatePrevious}
handleNavigateNext={handleNavigateNext}
handleCountItemsPerPageChange={(): void => {}}
showSizeChanger={false}
nextCursor={entryPagination.nextCursor}
/>
</div>
</div>
</div>
);
}
SpanTable.defaultProps = {
traceId: undefined,
setSelectedSpan: undefined,
isLoading: false,
};
export default SpanTable;

View File

@@ -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;
}

View File

@@ -0,0 +1,137 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { uniqBy } from 'lodash-es';
import { HierarchicalSpanData, ServiceEntrySpan, SpanDataRow } from './types';
export function transformEntrySpansToHierarchy(
entrySpans?: SpanDataRow[],
): HierarchicalSpanData {
let totalTraceTime = 0;
if (!entrySpans) {
return { entrySpans: [], totalTraceTime: 0 };
}
const uniqueEntrySpans = uniqBy(entrySpans, 'data.span_id');
// Calculate total trace time from all entry spans
uniqueEntrySpans.forEach((span) => {
totalTraceTime += span.data.duration_nano;
});
// Transform entry spans to ServiceEntrySpan structure
const entrySpansList: ServiceEntrySpan[] = uniqueEntrySpans.map((span) => ({
spanData: span,
serviceName: span.data['service.name'],
isExpanded: false,
serviceSpans: undefined,
isLoadingServiceSpans: false,
}));
return {
entrySpans: entrySpansList,
totalTraceTime,
};
}
export async function fetchServiceSpans(
traceId: string,
serviceName: string,
pageSize = 10,
offset = 0,
): Promise<{ spans: SpanDataRow[]; nextCursor: string }> {
// Use the same payload structure as in SpanList component but with service-specific filter
const payload = initialQueriesMap.traces;
try {
const response = await GetMetricQueryRange(
{
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
...payload,
builder: {
...payload.builder,
queryData: [
{
...payload.builder.queryData[0],
...{
name: 'A',
signal: 'traces',
stepInterval: null,
disabled: false,
filter: {
expression: `trace_id = '${traceId}' and service.name = '${serviceName}'`,
},
limit: pageSize,
offset,
order: [
{
key: {
name: 'timestamp',
},
direction: 'desc',
},
],
having: {
expression: '',
},
selectFields: [
{
name: 'service.name',
fieldDataType: 'string',
signal: 'traces',
fieldContext: 'resource',
},
{
name: 'name',
fieldDataType: 'string',
signal: 'traces',
},
{
name: 'duration_nano',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
fieldDataType: '',
signal: 'traces',
fieldContext: 'span',
},
],
},
},
],
},
},
},
ENTITY_VERSION_V5,
);
// Extract spans from the API response using the same path as SpanList component
const spans =
response?.payload?.data?.newResult?.data?.result?.[0]?.list || [];
const nextCursor =
response?.payload?.data?.newResult?.data?.result?.[0]?.nextCursor || '';
// Transform the API response to SpanDataRow format if needed
// The API should return the correct format for traces, but we'll handle any potential transformation
return {
spans: (spans as unknown) as SpanDataRow[],
nextCursor,
};
} catch (error) {
console.error('Failed to fetch service spans:', error);
return { spans: [], nextCursor: '' };
}
}

View File

@@ -502,8 +502,15 @@ function Success(props: ISuccessProps): JSX.Element {
isFilterActive,
],
);
const urlQuery = useUrlQuery();
const viewParam = urlQuery.get('view');
useEffect(() => {
if (viewParam === 'span-list') {
return;
}
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
@@ -526,7 +533,7 @@ function Success(props: ISuccessProps): JSX.Element {
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans]);
}, [interestedSpanId, setSelectedSpan, spans, viewParam]);
return (
<div className="success-content">

View File

@@ -1,5 +1,6 @@
import './TraceDetailV2.styles.scss';
import { UnorderedListOutlined } from '@ant-design/icons';
import {
ResizableHandle,
ResizablePanel,
@@ -10,14 +11,16 @@ import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
import cx from 'classnames';
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,
} from 'container/TraceWaterfall/TraceWaterfall';
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { defaultTo } from 'lodash-es';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
@@ -26,6 +29,11 @@ import NoData from './NoData/NoData';
function TraceDetailsV2(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV2URLProps>();
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const [activeTab, setActiveTab] = useState<string>(() => {
const viewParam = urlQuery.get('view');
return viewParam === 'span-list' ? 'span-list' : 'flamegraph';
});
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
@@ -67,6 +75,8 @@ function TraceDetailsV2(): JSX.Element {
useEffect(() => {
if (selectedSpan) {
setIsSpanDetailsDocked(false);
} else {
setIsSpanDetailsDocked(true);
}
}, [selectedSpan]);
@@ -88,6 +98,35 @@ function TraceDetailsV2(): JSX.Element {
}
}, [noData]);
const handleTabChange = useCallback(
(key: string) => {
const urlQueryParams = new URLSearchParams();
if (key === 'span-list') {
urlQueryParams.set('view', 'span-list');
setIsSpanDetailsDocked(true);
setSelectedSpan(undefined);
} else if (selectedSpan) {
setIsSpanDetailsDocked(false);
}
safeNavigate({ search: urlQueryParams.toString() });
setActiveTab(key);
},
[safeNavigate, selectedSpan],
);
// Sync tab state with URL changes
useEffect(() => {
const viewParam = urlQuery.get('view');
const newActiveTab = viewParam === 'span-list' ? 'span-list' : 'flamegraph';
if (newActiveTab !== activeTab) {
setActiveTab(newActiveTab);
}
}, [urlQuery, activeTab, selectedSpan]);
const items = [
{
label: (
@@ -124,6 +163,25 @@ function TraceDetailsV2(): JSX.Element {
</>
),
},
{
label: (
<Button
type="text"
icon={<UnorderedListOutlined />}
className="flamegraph-waterfall-toggle"
>
Span list
</Button>
),
key: 'span-list',
children: (
<SpanList
traceId={traceId}
setSelectedSpan={setSelectedSpan}
traceData={traceData}
/>
),
},
];
return (
@@ -147,7 +205,13 @@ function TraceDetailsV2(): JSX.Element {
notFound={noData}
/>
{!noData ? (
<Tabs items={items} animated className="trace-visualisation-tabs" />
<Tabs
items={items}
animated
className="trace-visualisation-tabs"
activeKey={activeTab}
onChange={handleTabChange}
/>
) : (
<NoData />
)}

View File

@@ -81,6 +81,7 @@ export interface QueryDataV3 {
}[];
columns: Column[];
};
nextCursor?: string;
}
export interface Props {

View File

@@ -4352,14 +4352,13 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/table@0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"
integrity sha512-XDwRHBTf2q48MOuxkzzr0htWd0/mmvgHoZLl0WAMLk2gddbbNHg9hkCPfVARYOke6mX8Z/4T3e7dzgkRUhDGDg==
"@signozhq/table@0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.4.0.tgz#35e40c8a40f362ce65e68258cd7b87c8474918ae"
integrity sha512-9Ktem3T9MHITh3+k1xTjhUpVpDJ5xNHijrUVuN6P9a4yP2/kqwXtHC4WGgOxJreUhp+bfvzQr956hR+ZAHnL3g==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
"@signozhq/tooltip" "0.0.2"
"@tanstack/react-table" "^8.21.3"
"@tanstack/react-virtual" "^3.13.9"
"@types/lodash-es" "^4.17.12"