Compare commits

...

4 Commits

Author SHA1 Message Date
Gaurav Tewari
0ae98b05c5 feat: loading in skeleton 2026-10-07 17:51:36 +05:30
Gaurav Tewari
7c76ba8d9e feat: update header options 2026-10-07 17:35:39 +05:30
Gaurav Tewari
104758f3cf refactor(trace-details): derive header filter range from the trace summary
Assisted-by: Claude Opus 5.5
2026-10-07 17:15:20 +05:30
Gaurav Tewari
504be0dfd7 feat(trace-details): read header metadata from the trace summary API
Assisted-by: Claude Opus 5.5
2026-10-07 16:59:38 +05:30
6 changed files with 132 additions and 99 deletions

View File

@@ -27,31 +27,14 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
showTraceDetailsHeaderOptions?: boolean;
}
const SKELETON_COUNT = 3;
@@ -73,16 +56,15 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
showTraceDetailsHeaderOptions,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID || '');
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -116,8 +98,11 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
@@ -142,7 +127,7 @@ function TraceDetailsHeader({
/>
</div>
)}
{isDataLoaded && (
{showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -171,9 +156,9 @@ function TraceDetailsHeader({
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
startTime={startTime}
endTime={endTime}
totalSpansCount={traceSummary.totalSpansCount}
/>
</div>
</TooltipProvider>
@@ -183,9 +168,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
startTime={startTime}
endTime={endTime}
traceID={traceID || ''}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -198,18 +183,18 @@ function TraceDetailsHeader({
{showTraceDetails && (
<div className={styles.subHeader}>
{traceMetadata ? (
{traceSummary ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceMetadata.rootSpanStatusCode}
statusCode={traceSummary.rootSpanStatusCode}
/>
) : (
<DetailsLoader />
@@ -217,7 +202,7 @@ function TraceDetailsHeader({
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -5,6 +5,11 @@ import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
}));
const mockGoBack = jest.fn();
const mockPush = jest.fn();
@@ -51,13 +56,19 @@ jest.mock('components/FieldsSelector', () => ({
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
showTraceDetailsHeaderOptions: false,
};
const SUMMARY = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000,
rootServiceName: 'frontend',
rootServiceEntryPoint: 'GET /checkout',
rootSpanStatusCode: '200',
hasMissingSpans: false,
totalSpansCount: 3,
totalErrorSpansCount: 0,
};
describe('TraceDetailsHeader – back button', () => {
@@ -92,10 +103,32 @@ describe('TraceDetailsHeader – back button', () => {
describe('TraceDetailsHeader – action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: SUMMARY, isLoading: false });
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
it('does not render the action buttons until the summary loads', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: true });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -106,7 +139,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -117,7 +150,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -133,7 +166,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// Plain prop, no API mock needed: traceMetadata is passed straight in.
// useTraceSummary is mocked, so no API call is made.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
@@ -142,16 +175,20 @@ describe('TraceDetailsHeader – trace metadata row', () => {
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
};
const mockSummary = (data?: typeof traceMetadata): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
afterEach(() => {
mockSummary(undefined);
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
@@ -166,13 +203,8 @@ describe('TraceDetailsHeader – trace metadata row', () => {
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
@@ -192,9 +224,12 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
it('shows skeletons instead of the metadata when the summary is absent', () => {
const { container } = render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});

View File

@@ -0,0 +1,16 @@
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
interface UseTraceSummaryResult {
data: SpantypesGettableTraceSummaryDTO | undefined;
isLoading: boolean;
}
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
const { data, isLoading } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId } },
);
return { data: data?.data, isLoading };
}

View File

@@ -28,7 +28,6 @@ import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -323,38 +322,6 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const filterMetadata = useMemo(
() => ({
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
traceId: traceId || '',
}),
[
traceData?.payload?.startTimestampMillis,
traceData?.payload?.endTimestampMillis,
traceId,
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -393,10 +360,10 @@ function TraceDetailsV3(): JSX.Element {
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
/>
{showNoData ? (

View File

@@ -44,6 +44,7 @@ import {
traceDetailFieldKeys,
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -149,6 +150,13 @@ export const traceDetailsMocks = defineStoryMocks({
),
),
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
response.json(() =>
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
),
),
rest.post(
'http://localhost/api/v3/traces/:traceId/flamegraph',
response.json(() => traceFlamegraphResponse(trace)),

View File

@@ -6,6 +6,7 @@
import type {
GetFlamegraph200,
GetTraceAggregations200,
GetTraceSummary200,
GetWaterfallV4200,
SpantypesFlamegraphSpanDTO,
SpantypesSpanAggregationDTO,
@@ -303,6 +304,27 @@ export const traceWaterfallResponse = (
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
const spans = buildSpans(options);
const root = spans[0];
return {
status: 'success',
data: {
startTimestampMillis: Math.round(options.traceStart),
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
rootServiceName: root?.template.service ?? '',
rootServiceEntryPoint: root?.template.name ?? '',
rootSpanStatusCode: root?.hasError ? '503' : '200',
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
},
};
};
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
spanId: span.spanId,
parentSpanId: span.parentSpanId,