Compare commits

...

15 Commits

Author SHA1 Message Date
aks07
07cdb2d4ae test(trace-details): add preview-fields hover card e2e 2026-06-25 00:01:40 +05:30
aks07
9318050f74 test(trace-details): add span details drawer e2e 2026-06-24 23:54:29 +05:30
aks07
626b6c3153 test(trace-details): add analytics panel e2e 2026-06-24 23:51:43 +05:30
aks07
8e9f533b58 test(trace-details): add highlight-errors filter e2e 2026-06-24 23:42:48 +05:30
aks07
3428d3a2e2 test(trace-details): add waterfall e2e + row instrumentation 2026-06-24 19:43:01 +05:30
aks07
521f43a37a test(trace-details): add flamegraph e2e + canvas test hook 2026-06-24 18:03:08 +05:30
aks07
a90e706038 test(trace-details): add e2e helper and large-trace fixture 2026-06-24 17:49:12 +05:30
aks07
8a4b234ee7 feat(trace-details): fix failing test 2026-06-22 18:55:28 +05:30
aks07
bddc61d22b feat(trace-details): remove unused trace details v2 code 2026-06-22 18:55:28 +05:30
aks07
604b5e2a4a feat(trace-details): remove Trace Details V2 page and its module import 2026-06-22 18:55:28 +05:30
aks07
e2d840345b fix(trace-details): fix serviceName path in trace funnel 2026-06-22 18:55:28 +05:30
aks07
e6071a7cb8 feat(trace-details): remove usage of getTraceV2 from V3 code 2026-06-22 18:55:28 +05:30
aks07
d4b3a34d10 feat(trace-details): move events out from v2 to v3 before cleanup 2026-06-22 18:55:28 +05:30
aks07
972cd00c68 feat(trace-details): move span logs out from v2 to v3 before cleanup 2026-06-22 18:55:28 +05:30
aks07
9aff84c276 feat(trace-details): move no-data component from v2 code to v3 before v2 cleanup 2026-06-22 18:55:28 +05:30
104 changed files with 4120 additions and 11125 deletions

View File

@@ -57,13 +57,6 @@ export const TraceFilter = Loadable(
() => import(/* webpackChunkName: "Trace Filter Page" */ 'pages/Trace'),
);
export const TraceDetail = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetail Page" */ 'pages/TraceDetailV2/index'
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(

View File

@@ -1,35 +0,0 @@
import { ApiV2Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const getTraceV2 = async (
props: GetTraceV2PayloadProps,
): Promise<SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
}
const postData: GetTraceV2PayloadProps = {
...props,
uncollapsedSpans,
};
const response = await axios.post<GetTraceV2SuccessResponse>(
`/traces/waterfall/${props.traceId}`,
omit(postData, 'traceId'),
);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
};
export default getTraceV2;

View File

@@ -41,6 +41,7 @@ const getTraceV4 = async (
> & { spans: WireSpan[] | null };
// Derive 'service.name' from resource for convenience — only derived field
// todo(tech-debt): to remove use of this and to directly use service.name from resources.
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
...span,
'service.name': span.resource?.['service.name'] || '',

View File

@@ -1,25 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { useIsDarkMode } from 'hooks/useDarkMode';
function FlamegraphImg(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 3c1 3 2.5 3.5 3.5 4.5A5 5 0 0113 11a5 5 0 11-10 0c0-.3 0-.6.1-.9a2 2 0 103.3-2C4 5.5 7 3 8 3zM21 4h-8M20 14.5h-3M20 9.5h-3M21 20H4"
stroke={isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default FlamegraphImg;

View File

@@ -1,106 +0,0 @@
.span-hover-card {
.ant-popover-inner {
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
padding: 12px 16px;
border: 1px solid var(--l1-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
backdrop-filter: blur(20px);
border-radius: 4px;
z-index: -1;
will-change: background-color, backdrop-filter;
}
}
&__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
&__operation {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.48px;
}
&__service {
font-size: 0.875rem;
color: var(--muted-foreground);
font-weight: 400;
}
&__error {
font-size: 0.75rem;
color: var(--danger-background);
font-weight: 500;
}
&__row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
gap: 16px;
}
&__label {
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&__value {
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
text-align: right;
}
&__relative-time {
display: flex;
align-items: center;
margin-top: 4px;
gap: 8px;
border-radius: 1px 0 0 1px;
background: linear-gradient(
90deg,
hsla(358, 75%, 59%, 0.2) 0%,
transparent 100%
);
&-icon {
width: 2px;
height: 20px;
flex-shrink: 0;
border-radius: 2px;
background: var(--danger-background);
}
}
&__relative-text {
color: var(--bg-cherry-300);
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -1,103 +0,0 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const duration = span.durationNano / 1e6; // Convert nanoseconds to milliseconds
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(duration);
const { timezone } = useTimezone();
// Calculate relative start time from trace start
const relativeStartTime = span.timestamp - traceMetadata.startTime;
const { time: relativeTime, timeUnitName: relativeTimeUnit } =
convertTimeToRelevantUnit(relativeStartTime);
// Format absolute start time
const startTimeFormatted = dayjs(span.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS);
const getContent = (): JSX.Element => (
<div className="span-hover-card">
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Duration:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{toFixed(formattedDuration, 2)}
{timeUnitName}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Events:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{span.event?.length || 0}
</Typography.Text>
</div>
<div className="span-hover-card__row">
<Typography.Text className="span-hover-card__label">
Start time:
</Typography.Text>
<Typography.Text className="span-hover-card__value">
{startTimeFormatted}
</Typography.Text>
</div>
<div className="span-hover-card__relative-time">
<div className="span-hover-card__relative-time-icon" />
<Typography.Text className="span-hover-card__relative-text">
{toFixed(relativeTime, 2)}
{relativeTimeUnit} after trace start
</Typography.Text>
</div>
</div>
);
return (
<Popover
title={
<div className="span-hover-card__title">
<Typography.Text className="span-hover-card__operation">
{span.name}
</Typography.Text>
</div>
}
mouseEnterDelay={0.2}
content={getContent()}
trigger="hover"
rootClassName="span-hover-card"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -1,291 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { TimezoneContextType } from 'providers/Timezone';
import { Span } from 'types/api/trace/getTraceV2';
import SpanHoverCard from '../SpanHoverCard';
// Mock timezone provider so SpanHoverCard can use useTimezone without a real context
jest.mock('providers/Timezone', () => ({
__esModule: true,
useTimezone: (): TimezoneContextType => ({
timezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
browserTimezone: {
name: 'Coordinated Universal Time — UTC, GMT',
value: 'UTC',
offset: 'UTC',
searchIndex: 'UTC',
},
updateTimezone: jest.fn(),
formatTimezoneAdjustedTimestamp: jest.fn(() => 'mock-date'),
isAdaptationEnabled: true,
setIsAdaptationEnabled: jest.fn(),
}),
}));
// Mock dayjs for testing, including timezone helpers used in timezoneUtils
jest.mock('dayjs', () => {
const mockDayjsInstance: any = {};
mockDayjsInstance.format = jest.fn((formatString: string) =>
// Match the DD_MMM_YYYY_HH_MM_SS format: 'DD MMM YYYY, HH:mm:ss'
formatString === 'DD MMM YYYY, HH:mm:ss'
? '15 Mar 2024, 14:23:45'
: 'mock-date',
);
// Support chaining: dayjs().tz(timezone).format(...) and dayjs().tz(timezone).utcOffset()
mockDayjsInstance.tz = jest.fn(() => mockDayjsInstance);
mockDayjsInstance.utcOffset = jest.fn(() => 0);
const mockDayjs = jest.fn(() => mockDayjsInstance);
Object.assign(mockDayjs, {
extend: jest.fn(),
// Support dayjs.tz.guess()
tz: { guess: jest.fn(() => 'UTC') },
});
return mockDayjs;
});
const HOVER_ELEMENT_ID = 'hover-element';
const mockSpan: Span = {
spanId: 'test-span-id',
traceId: 'test-trace-id',
rootSpanId: 'root-span-id',
parentSpanId: 'parent-span-id',
name: 'GET /api/users',
timestamp: 1679748225000000,
durationNano: 150000000,
serviceName: 'user-service',
kind: 1,
hasError: false,
level: 1,
references: [],
tagMap: {},
event: [
{
name: 'event1',
timeUnixNano: 1679748225100000,
attributeMap: {},
isError: false,
},
{
name: 'event2',
timeUnixNano: 1679748225200000,
attributeMap: {},
isError: false,
},
],
rootName: 'root-span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
};
const mockTraceMetadata = {
startTime: 1679748225000000,
endTime: 1679748226000000,
};
describe('SpanHoverCard', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('renders child element correctly', () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid="child-element">Hover me</div>
</SpanHoverCard>,
);
expect(screen.getByTestId('child-element')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});
it('shows popover after 0.2 second delay on hover', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover over the element
fireEvent.mouseEnter(hoverElement);
// Popover should NOT appear immediately
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Advance time by 0.5 seconds
act(() => {
jest.advanceTimersByTime(200);
});
// Now popover should appear
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
it('does not show popover if hover is too brief', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Quick hover and unhover (less than the 0.2s delay)
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(100); // Only 0.1 seconds
});
fireEvent.mouseLeave(hoverElement);
// Advance past the full delay
act(() => {
jest.advanceTimersByTime(400);
});
// Popover should not appear
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
});
it('displays span information in popover content after delay', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check that popover shows span operation name in title
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
// Check duration information
expect(screen.getByText('Duration:')).toBeInTheDocument();
expect(screen.getByText('150ms')).toBeInTheDocument();
// Check events count
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
// Check start time label
expect(screen.getByText('Start time:')).toBeInTheDocument();
});
it('displays date in DD MMM YYYY, HH:mm:ss format with seconds', async () => {
render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Verify the DD MMM YYYY, HH:mm:ss format is displayed
expect(screen.getByText('15 Mar 2024, 14:23:45')).toBeInTheDocument();
});
it('displays relative time information', async () => {
const spanWithRelativeTime: Span = {
...mockSpan,
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
};
render(
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
// Check relative time display
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
});
it('handles spans with no events correctly', async () => {
const spanWithoutEvents: Span = {
...mockSpan,
event: [],
};
render(
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
</SpanHoverCard>,
);
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
// Hover and wait for popover
fireEvent.mouseEnter(hoverElement);
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Events:')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('verifies mouseEnterDelay prop is set to 0.5', () => {
const { container } = render(
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
</SpanHoverCard>,
);
// The mouseEnterDelay prop should be set on the Popover component
// This test verifies the implementation includes the delay
const popover = container.querySelector('.ant-popover');
expect(popover).not.toBeInTheDocument(); // Initially not visible
// Hover to trigger delay mechanism
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
fireEvent.mouseEnter(hoverElement);
// Should not appear before delay
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
// Should appear after delay
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByText('Duration:')).toBeInTheDocument();
});
});

View File

@@ -1,108 +0,0 @@
.flamegraph {
display: flex;
height: 30vh;
border-bottom: 1px solid var(--l1-border);
.flamegraph-chart {
padding: 15px;
.loading-skeleton {
justify-content: center;
align-items: center;
}
}
.flamegraph-stats {
display: flex;
flex-direction: column;
border-right: 1px solid var(--l1-border);
overflow-y: auto;
overflow-x: hidden;
padding: 16px 20px;
.exec-time-service {
display: flex;
height: 30px;
flex-shrink: 0;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
letter-spacing: -0.06px;
}
.stats {
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 0rem;
}
.value-row {
display: flex;
justify-content: space-between;
.service-name {
display: flex;
align-items: center;
gap: 8px;
width: 80%;
.service-text {
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
width: 80%;
}
.square-box {
height: 8px;
width: 8px;
}
}
.progress-service {
display: flex;
align-items: center;
width: 100px;
gap: 8px;
justify-content: flex-start;
flex-shrink: 0;
.service-progress-indicator {
width: fit-content;
--progress-width: 30px;
}
.percent-value {
color: var(--l1-foreground);
text-align: right;
font-family: 'Inter';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.48px;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
}
}
}
}
}
}

View File

@@ -1,186 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Progress } from '@signozhq/ui/progress';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { themeColors } from 'constants/theme';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { TraceFlamegraphStates } from './constants';
import Error from './TraceFlamegraphStates/Error/Error';
import NoData from './TraceFlamegraphStates/NoData/NoData';
import Success from './TraceFlamegraphStates/Success/Success';
import './PaginatedTraceFlamegraph.styles.scss';
interface ITraceFlamegraphProps {
serviceExecTime: Record<string, number>;
startTime: number;
endTime: number;
traceFlamegraphStatsWidth: number;
selectedSpan: Span | undefined;
}
function TraceFlamegraph(props: ITraceFlamegraphProps): JSX.Element {
const {
serviceExecTime,
startTime,
endTime,
traceFlamegraphStatsWidth,
selectedSpan,
} = props;
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
useEffect(() => {
setFirstSpanAtFetchLevel(urlQuery.get('spanId') || '');
}, [urlQuery]);
const { data, isFetching, error } = useGetTraceFlamegraph({
traceId,
selectedSpanId: firstSpanAtFetchLevel,
});
// get the current state of trace flamegraph based on the API lifecycle
const traceFlamegraphState = useMemo(() => {
if (isFetching) {
if (
data &&
data.payload &&
data.payload.spans &&
data.payload.spans.length > 0
) {
return TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceFlamegraphStates.LOADING;
}
if (error) {
return TraceFlamegraphStates.ERROR;
}
if (
data &&
data.payload &&
data.payload.spans &&
data.payload.spans.length === 0
) {
return TraceFlamegraphStates.NO_DATA;
}
return TraceFlamegraphStates.SUCCESS;
}, [error, isFetching, data]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceFlamegraphState) {
case TraceFlamegraphStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 3 }} />
</div>
);
case TraceFlamegraphStates.ERROR:
return <Error error={error as AxiosError} />;
case TraceFlamegraphStates.NO_DATA:
return <NoData id={traceId} />;
case TraceFlamegraphStates.SUCCESS:
case TraceFlamegraphStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
firstSpanAtFetchLevel={firstSpanAtFetchLevel}
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
traceMetadata={{
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
selectedSpan={selectedSpan}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
error,
firstSpanAtFetchLevel,
selectedSpan,
spans,
traceFlamegraphState,
traceId,
]);
const spread = useMemo(() => endTime - startTime, [endTime, startTime]);
return (
<div className="flamegraph">
<div
className="flamegraph-stats"
style={{ width: `${traceFlamegraphStatsWidth + 22}px` }}
>
<div className="exec-time-service">% exec time</div>
<div className="stats">
{Object.keys(serviceExecTime)
.sort((a, b) => {
if (spread <= 0) {
return 0;
}
const aValue = (serviceExecTime[a] * 100) / spread;
const bValue = (serviceExecTime[b] * 100) / spread;
return bValue - aValue;
})
.map((service) => {
const value =
spread <= 0 ? 0 : (serviceExecTime[service] * 100) / spread;
const color = generateColor(service, themeColors.traceDetailColors);
return (
<div key={service} className="value-row">
<section className="service-name">
<div className="square-box" style={{ backgroundColor: color }} />
<Tooltip title={service}>
<Typography.Text className="service-text" truncate={1}>
{service}
</Typography.Text>
</Tooltip>
</section>
<section className="progress-service">
<Progress
percent={parseFloat(value.toFixed(2))}
className="service-progress-indicator"
showInfo={false}
/>
<Typography.Text className="percent-value">
{parseFloat(value.toFixed(2))}%
</Typography.Text>
</section>
</div>
);
})}
</div>
</div>
<div
className="flamegraph-chart"
style={{ width: `calc(100% - ${traceFlamegraphStatsWidth + 22}px)` }}
>
{getContent}
</div>
</div>
);
}
export default TraceFlamegraph;

View File

@@ -1,23 +0,0 @@
.error-flamegraph {
display: flex;
gap: 4px;
flex-direction: column;
justify-content: center;
align-items: center;
height: 15vh;
.error-flamegraph-img {
height: 32px;
width: 32px;
}
.no-data-text {
color: var(--muted-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,32 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import noDataUrl from '@/assets/Icons/no-data.svg';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-flamegraph">
<img
src={noDataUrl}
alt="error-flamegraph"
className="error-flamegraph-img"
/>
<Tooltip title={error?.message}>
<Typography.Text className="no-data-text">
{error?.message || 'Something went wrong!'}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -1,12 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -1,49 +0,0 @@
.trace-flamegraph {
height: 90%;
overflow-x: hidden;
overflow-y: auto;
.trace-flamegraph-virtuoso {
overflow-x: hidden;
.flamegraph-row {
display: flex;
align-items: center;
height: 18px;
padding-bottom: 6px;
.span-item {
position: absolute;
height: 12px;
background-color: yellow;
border-radius: 6px;
cursor: pointer;
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--primary-background);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--danger-background);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
}
&::-webkit-scrollbar {
width: 0rem;
}
}
}

View File

@@ -1,178 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { ListRange, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Tooltip } from 'antd';
import Color from 'color';
import TimelineV2 from 'components/TimelineV2/TimelineV2';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './Success.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
interface ISuccessProps {
spans: FlamegraphSpan[][];
firstSpanAtFetchLevel: string;
setFirstSpanAtFetchLevel: Dispatch<SetStateAction<string>>;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
}
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
setFirstSpanAtFetchLevel,
traceMetadata,
firstSpanAtFetchLevel,
selectedSpan,
} = props;
const { search } = useLocation();
const history = useHistory();
const isDarkMode = useIsDarkMode();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [hoveredSpanId, setHoveredSpanId] = useState<string>('');
const renderSpanLevel = useCallback(
(_: number, spans: FlamegraphSpan[]): JSX.Element => (
<div className="flamegraph-row">
{spans.map((span) => {
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset =
((span.timestamp - traceMetadata.startTime) * 100) / spread;
let width = ((span.durationNano / 1e6) * 100) / spread;
if (width > 100) {
width = 100;
}
const toolTipText = `${span.name}`;
const searchParams = new URLSearchParams(search);
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
const selectedSpanColor = isDarkMode
? Color(color).lighten(0.7)
: Color(color).darken(0.7);
if (span.hasError) {
color = `var(--danger-background)`;
}
return (
<Tooltip title={toolTipText} key={span.spanId}>
<div
className="span-item"
style={{
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor:
selectedSpan?.spanId === span.spanId || hoveredSpanId === span.spanId
? `${selectedSpanColor}`
: color,
}}
onMouseEnter={(): void => setHoveredSpanId(span.spanId)}
onMouseLeave={(): void => setHoveredSpanId('')}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
searchParams.set('spanId', span.spanId);
history.replace({ search: searchParams.toString() });
}}
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
</Tooltip>
);
})}
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[traceMetadata.endTime, traceMetadata.startTime, selectedSpan, hoveredSpanId],
);
const handleRangeChanged = useCallback(
(range: ListRange) => {
// if there are less than 50 levels on any load that means a single API call is sufficient
if (spans.length < 50) {
return;
}
const { startIndex, endIndex } = range;
if (startIndex === 0 && spans[0][0].level !== 0) {
setFirstSpanAtFetchLevel(spans[0][0].spanId);
}
if (endIndex === spans.length - 1) {
setFirstSpanAtFetchLevel(spans[spans.length - 1][0].spanId);
}
},
[setFirstSpanAtFetchLevel, spans],
);
useEffect(() => {
const index = spans.findIndex(
(span) => span[0].spanId === firstSpanAtFetchLevel,
);
virtuosoRef.current?.scrollToIndex({
index,
behavior: 'auto',
});
}, [firstSpanAtFetchLevel, spans]);
return (
<>
<div className="trace-flamegraph">
<Virtuoso
ref={virtuosoRef}
className="trace-flamegraph-virtuoso"
data={spans}
itemContent={renderSpanLevel}
rangeChanged={handleRangeChanged}
/>
</div>
<TimelineV2
startTimestamp={traceMetadata.startTime}
endTimestamp={traceMetadata.endTime}
timelineHeight={22}
/>
</>
);
}
export default Success;

View File

@@ -1,7 +0,0 @@
export enum TraceFlamegraphStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -1,202 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Spin, Tooltip } from 'antd';
import GroupByIcon from 'assets/CustomIcons/GroupByIcon';
import cx from 'classnames';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { useTraceActions } from 'hooks/trace/useTraceActions';
import {
ArrowDownToDot,
ArrowUpFromDot,
Copy,
Ellipsis,
Pin,
} from '@signozhq/icons';
interface AttributeRecord {
field: string;
value: string;
}
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin?: (fieldKey: string) => void;
showPinned?: boolean;
showCopyOptions?: boolean;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
showPinned = true,
showCopyOptions = true,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
const [isFilterOutLoading, setIsFilterOutLoading] = useState<boolean>(false);
const { onAddToQuery, onGroupByAttribute, onCopyFieldName, onCopyFieldValue } =
useTraceActions();
const textToCopy = useMemo(() => {
const str = record.value == null ? '' : String(record.value);
// Remove surrounding double-quotes only (e.g., JSON-encoded string values)
return str.replace(/^"|"$/g, '');
}, [record.value]);
const handleFilterIn = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterInLoading) {
return;
}
setIsFilterInLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['=']),
);
} finally {
setIsFilterInLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterInLoading]);
const handleFilterOut = useCallback(async (): Promise<void> => {
if (!onAddToQuery || isFilterOutLoading) {
return;
}
setIsFilterOutLoading(true);
try {
await Promise.resolve(
onAddToQuery(record.field, record.value, OPERATORS['!=']),
);
} finally {
setIsFilterOutLoading(false);
}
}, [onAddToQuery, record.field, record.value, isFilterOutLoading]);
const handleGroupBy = useCallback((): void => {
if (onGroupByAttribute) {
onGroupByAttribute(record.field);
}
setIsOpen(false);
}, [onGroupByAttribute, record.field]);
const handleCopyFieldName = useCallback((): void => {
if (onCopyFieldName) {
onCopyFieldName(record.field);
}
setIsOpen(false);
}, [onCopyFieldName, record.field]);
const handleCopyFieldValue = useCallback((): void => {
if (onCopyFieldValue) {
onCopyFieldValue(textToCopy);
}
setIsOpen(false);
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin?.(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
<div className="attribute-actions-menu">
<Button
className="group-by-clause"
type="text"
icon={<GroupByIcon />}
onClick={handleGroupBy}
block
>
Group By Attribute
</Button>
{showCopyOptions && (
<>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</>
)}
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
{showPinned && (
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
)}
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter for value"
disabled={isFilterInLoading}
icon={
isFilterInLoading ? (
<Spin size="small" />
) : (
<ArrowDownToDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterIn}
/>
</Tooltip>
<Tooltip title="Filter out value">
<Button
className="filter-btn periscope-btn"
aria-label="Filter out value"
disabled={isFilterOutLoading}
icon={
isFilterOutLoading ? (
<Spin size="small" />
) : (
<ArrowUpFromDot size={14} style={{ transform: 'rotate(90deg)' }} />
)
}
onClick={handleFilterOut}
/>
</Tooltip>
<Popover
open={isOpen}
onOpenChange={setIsOpen}
arrow={false}
content={moreActionsContent}
rootClassName="attribute-actions-content"
trigger="hover"
placement="bottomLeft"
>
<Button
data-testid="attribute-actions-more"
aria-label="More attribute actions"
icon={<Ellipsis size={14} />}
className="filter-btn periscope-btn"
/>
</Popover>
</div>
);
}
AttributeActions.defaultProps = {
isPinned: false,
showPinned: true,
showCopyOptions: true,
onTogglePin: undefined,
};

View File

@@ -1,151 +0,0 @@
.attributes-corner {
display: flex;
flex-direction: column;
.no-data {
height: 400px;
justify-content: center;
align-items: center;
}
.search-input {
margin: 12px;
width: auto;
}
.attributes-container {
display: flex;
flex-direction: column;
gap: 12px;
padding-block: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
position: relative;
padding: 2px 12px;
&:hover {
background-color: var(--l1-border);
.action-btn {
display: flex;
}
}
.item-key-wrapper {
display: flex;
align-items: center;
gap: 6px;
.pin-icon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
}
.item-key {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.copy-wrapper {
overflow: hidden;
}
.item-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
.action-btn {
display: none;
&--is-open {
display: flex;
}
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
gap: 4px;
border-radius: 4px;
padding: 2px;
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l1-border);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--l3-background);
}
}
}
}
}
.border-top {
border-top: 1px solid var(--l1-border);
}
}
.attribute-actions-menu {
display: flex;
flex-direction: column;
gap: 4px;
.ant-btn {
text-align: left;
height: auto;
padding: 6px 12px;
display: flex;
align-items: center;
gap: 8px;
&:hover {
background-color: var(--l1-border);
}
}
.group-by-clause {
color: var(--text-primary);
}
}
.attribute-actions-content {
.ant-popover-inner {
padding: 8px;
min-width: 160px;
}
}

View File

@@ -1,135 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { flattenObject } from 'container/LogDetailedView/utils';
import { usePinnedAttributes } from 'hooks/spanDetails/usePinnedAttributes';
import { Pin } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
import NoData from '../NoData/NoData';
import AttributeActions from './AttributeActions';
import './Attributes.styles.scss';
interface AttributeRecord {
field: string;
value: string;
}
interface IAttributesProps {
span: Span;
isSearchVisible: boolean;
shouldFocusOnToggle?: boolean;
}
function Attributes(props: IAttributesProps): JSX.Element {
const { span, isSearchVisible, shouldFocusOnToggle } = props;
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
const flattenSpanData: Record<string, string> = useMemo(
() => (span.tagMap ? flattenObject(span.tagMap) : {}),
[span],
);
const availableAttributes = useMemo(
() => Object.keys(flattenSpanData),
[flattenSpanData],
);
const { pinnedAttributes, togglePin } =
usePinnedAttributes(availableAttributes);
const sortPinnedAttributes = useCallback(
(data: AttributeRecord[]): AttributeRecord[] =>
data.sort((a, b) => {
const aIsPinned = pinnedAttributes[a.field];
const bIsPinned = pinnedAttributes[b.field];
if (aIsPinned && !bIsPinned) {
return -1;
}
if (!aIsPinned && bIsPinned) {
return 1;
}
// Within same pinning status, maintain alphabetical order
return a.field.localeCompare(b.field);
}),
[pinnedAttributes],
);
const datasource = useMemo(() => {
const filtered = Object.keys(flattenSpanData)
.filter((attribute) =>
attribute.toLowerCase().includes(fieldSearchInput.toLowerCase()),
)
.map((key) => ({ field: key, value: flattenSpanData[key] }));
return sortPinnedAttributes(filtered);
}, [flattenSpanData, fieldSearchInput, sortPinnedAttributes]);
return (
<div className="attributes-corner">
{isSearchVisible &&
(datasource.length > 0 || fieldSearchInput.length > 0) && (
<Input
autoFocus={shouldFocusOnToggle}
placeholder="Search for attribute..."
className="search-input"
value={fieldSearchInput}
onChange={(e): void => setFieldSearchInput(e.target.value)}
/>
)}
{datasource.length === 0 && fieldSearchInput.length === 0 && (
<NoData name="attributes" />
)}
<section
className={cx('attributes-container', isSearchVisible ? 'border-top' : '')}
>
{datasource
.filter((item) => !!item.value && item.value !== '-')
.map((item) => (
<div
className={cx('item', { pinned: pinnedAttributes[item.field] })}
key={`${item.field} + ${item.value}`}
>
<div className="item-key-wrapper">
<Typography.Text className="item-key" truncate={1}>
{item.field}
</Typography.Text>
{pinnedAttributes[item.field] && (
<Pin size={14} className="pin-icon" fill="currentColor" />
)}
</div>
<div className="value-wrapper">
<div className="copy-wrapper">
<CopyClipboardHOC
entityKey={item.value}
textToCopy={item.value}
tooltipText={item.value}
>
<Typography.Text className="item-value" truncate={1}>
{item.value}
</Typography.Text>
</CopyClipboardHOC>
</div>
<AttributeActions
record={item}
isPinned={pinnedAttributes[item.field]}
onTogglePin={togglePin}
/>
</div>
</div>
))}
</section>
</div>
);
}
Attributes.defaultProps = {
shouldFocusOnToggle: false,
};
export default Attributes;

View File

@@ -1,142 +0,0 @@
.events-table {
.no-events {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.events-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.event {
.ant-collapse {
border: none;
}
.ant-collapse-content {
border-top: none;
}
.ant-collapse-item {
border-bottom: 0px;
}
.ant-collapse-content-box {
border: 1px solid var(--l1-border);
border-top: none;
}
.ant-collapse-header {
display: flex;
padding: 8px 6px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
.ant-collapse-expand-icon {
padding-inline-start: 0px;
padding-inline-end: 0px;
}
.collapse-title {
display: flex;
align-items: center;
gap: 6px;
.diamond {
fill: var(--accent-primary);
}
}
}
.event-details {
display: flex;
flex-direction: column;
gap: 16px;
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
.attribute-key {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.timestamp-container {
display: flex;
gap: 4px;
align-items: center;
.timestamp-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.attribute-value {
display: flex;
padding: 2px 8px;
width: fit-content;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.wrapper {
display: flex;
padding: 2px 8px;
width: fit-content;
max-width: 100%;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.attribute-value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
}
}
}

View File

@@ -1,31 +0,0 @@
.attribute-with-expandable-popover {
&__popover {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 50vw;
}
&__preview {
max-height: 40vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
padding: 8px;
border-radius: 4px;
}
&__expand-button {
align-self: flex-end;
display: flex;
align-items: center;
flex-grow: 0;
}
&__full-view {
max-height: 70vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
}

View File

@@ -1,52 +0,0 @@
.no-linked-spans {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.linked-spans-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
.item {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-start;
.item-key {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.value-wrapper {
display: flex;
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.item-value {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.56px;
}
}
}
}

View File

@@ -1,81 +0,0 @@
import { useCallback } from 'react';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ROUTES from 'constants/routes';
import { formUrlParams } from 'container/TraceDetail/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { withBasePath } from 'utils/basePath';
import NoData from '../NoData/NoData';
import './LinkedSpans.styles.scss';
interface LinkedSpansProps {
span: Span;
}
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
function LinkedSpans(props: LinkedSpansProps): JSX.Element {
const { span } = props;
const getLink = useCallback((item: SpanReference): string | null => {
if (!item.traceId || !item.spanId) {
return null;
}
return withBasePath(
`${ROUTES.TRACE}/${item.traceId}${formUrlParams({
spanId: item.spanId,
levelUp: 0,
levelDown: 0,
})}`,
);
}, []);
// Filter out CHILD_OF references as they are parent-child relationships
const linkedSpans =
span.references?.filter((ref: SpanReference) => ref.refType !== 'CHILD_OF') ||
[];
if (linkedSpans.length === 0) {
return (
<div className="no-linked-spans">
<NoData name="linked spans" />
</div>
);
}
return (
<div className="linked-spans-container">
{linkedSpans.map((item: SpanReference) => {
const link = getLink(item);
return (
<div className="item" key={item.spanId}>
<Typography.Text className="item-key" truncate={1}>
Linked Span ID
</Typography.Text>
<div className="value-wrapper">
<Tooltip title={item.spanId}>
{link ? (
<Typography.Link href={link} className="item-value" truncate={1}>
{item.spanId}
</Typography.Link>
) : (
<Button type="link" className="item-value" disabled>
{item.spanId}
</Button>
)}
</Tooltip>
</div>
</div>
);
})}
</div>
);
}
export default LinkedSpans;

View File

@@ -1,20 +0,0 @@
.no-data {
display: flex;
gap: 4px;
flex-direction: column;
.no-data-img {
height: 32px;
width: 32px;
}
.no-data-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,687 +0,0 @@
.span-details-drawer {
display: flex;
flex-direction: column;
height: calc(100vh - 44px); //44px -> trace details top bar
border-left: 1px solid var(--l1-border);
overflow-y: auto !important;
&:not(&-docked) {
min-width: 450px;
}
&::-webkit-scrollbar {
width: 0.1rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
.heading {
display: flex;
align-items: center;
gap: 8px;
.dot {
height: 8px;
width: 8px;
border-radius: 2px;
background: var(--danger-background);
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.description {
display: flex;
flex-direction: column;
padding: 10px 0px;
.item {
padding: 8px 12px;
&,
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
position: relative; // ensure absolutely-positioned children anchor to the row
}
// Show attribute actions on hover for hardcoded rows
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
// style the action button group
.action-btn {
display: flex;
gap: 4px;
}
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--l1-border);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--l3-background);
}
}
}
&:hover {
background-color: var(--l1-border);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
.loading-spinner-container {
padding: 4px 8px;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
display: inline-flex;
}
.span-percentile-value-container {
.span-percentile-value {
color: var(--bg-sakura-400);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
border-radius: 0 50px 50px 0;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
min-width: 48px;
padding-left: 8px;
padding-right: 8px;
border-left: 1px solid var(--l1-border);
cursor: pointer;
display: inline-flex;
align-items: center;
word-break: normal;
gap: 6px;
}
&.span-percentile-value-container-open {
.span-percentile-value {
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
}
}
}
.span-percentiles-container {
display: flex;
flex-direction: column;
position: relative;
fill: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 32%, transparent) 0%,
color-mix(in srgb, var(--card) 36%, transparent) 98.68%
);
stroke-width: 1px;
stroke: var(--l1-border);
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
border: 1px solid var(--l1-border);
border-radius: 4px;
.span-percentiles-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 8px 12px;
border-bottom: 1px solid var(--l1-border);
.span-percentiles-header-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
}
.span-percentile-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
.span-percentile-content-title {
.span-percentile-value {
color: var(--bg-sakura-400);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
}
.span-percentile-value-loader {
display: inline-flex;
align-items: flex-end;
justify-content: flex-end;
margin-right: 4px;
margin-left: 4px;
line-height: 18px;
}
}
.span-percentile-timerange {
width: 100%;
.span-percentile-timerange-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
height: 32px;
}
}
}
.span-percentile-values-table {
.span-percentile-values-table-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.span-percentile-values-table-header {
color: var(--l2-foreground);
text-align: right;
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 181.818% */
text-transform: uppercase;
}
}
.span-percentile-values-table-data-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
.span-percentile-values-table-data-rows-skeleton {
display: flex;
flex-direction: column;
gap: 4px;
.ant-skeleton-title {
width: 100% !important;
margin-top: 0px !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
}
.span-percentile-values-table-data-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0px 4px;
.span-percentile-values-table-data-row-key {
flex: 0 0 auto;
color: var(--l1-foreground);
text-align: right;
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
.span-percentile-values-table-data-row-value {
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on,
'ss02' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
.dashed-line {
flex: 1;
height: 0; /* line only */
margin: 0 8px;
border-top: 1px dashed var(--l1-border);
/* Use border image to control dash length & spacing */
border-top-width: 1px;
border-top-style: solid; /* temporary solid for image */
border-image: repeating-linear-gradient(
to right,
#1d212d 0,
#1d212d 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
}
.current-span-percentile-row {
border-radius: 2px;
background: color-mix(
in srgb,
var(--primary-background) 20%,
transparent
);
.span-percentile-values-table-data-row-key {
color: var(--text-robin-300);
}
.dashed-line {
flex: 1;
height: 0; /* line only */
margin: 0 8px;
border-top: 1px dashed #abbdff;
/* Use border image to control dash length & spacing */
border-top-width: 1px;
border-top-style: solid; /* temporary solid for image */
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-values-table-data-row-value {
color: var(--text-robin-400);
}
}
}
}
.resource-attributes-select-container {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
.resource-attributes-select-container-header {
.resource-attributes-select-container-input {
border-radius: 0px;
border: none !important;
box-shadow: none !important;
height: 36px;
border-bottom: 1px solid var(--l1-border) !important;
}
}
border-radius: 4px;
border: 1px solid var(--l1-border);
background: linear-gradient(139deg, var(--card) 0%, var(--card) 98.68%);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select {
width: 100%;
}
.resource-attributes-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
}
.resource-attributes-select-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 8px 12px;
.resource-attributes-select-item-checkbox {
.ant-checkbox-disabled {
background-color: var(--primary-background);
color: var(--l1-foreground);
}
.resource-attributes-select-item-value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
}
.attribute-key {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.attribute-container .wrapper,
.value-wrapper {
display: flex;
align-items: center;
width: fit-content;
max-width: 100%;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.attribute-value {
padding: 2px 8px;
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
width: 100%;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.service {
display: flex;
padding: 2px 8px;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
width: fit-content;
.dot {
height: 4px;
width: 4px;
}
.value-wrapper {
display: flex;
padding: 0px;
align-items: center;
width: fit-content;
max-width: 100%;
border-radius: 0px;
border: none;
background: var(--l1-border);
.service-value {
color: var(--l2-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
}
.related-signals-section {
.view-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
line-height: 30px;
}
.ant-btn.ant-btn-default {
padding: 0 15px;
&:not(:hover) {
border: 1px solid var(--l1-border);
}
}
}
}
}
.attributes-events {
.details-drawer-tabs {
.ant-tabs-extra-content {
display: flex;
align-items: center;
.search-icon {
width: 33px;
padding-right: 12px;
}
}
.ant-tabs-nav::before {
border-bottom: 1px solid var(--l1-border) !important;
}
.ant-tabs-tab {
margin: 0 !important;
padding: 0 2px !important;
min-width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.attributes-tab-btn,
.events-tab-btn,
.linked-spans-tab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 4px 8px;
margin-right: 8px;
gap: 4px;
.tab-label {
display: flex;
align-items: center;
}
.count-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: color-mix(in srgb, var(--bg-robin-200) 10%, transparent);
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.065px;
text-transform: uppercase;
}
}
.attributes-tab-btn:hover,
.events-tab-btn:hover,
.linked-spans-tab-btn:hover {
background: unset;
}
}
}
}
.span-percentile-tooltip {
.ant-tooltip-content {
width: 300px;
max-width: 300px;
}
.span-percentile-tooltip-text {
color: var(--l2-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
letter-spacing: -0.06px;
.span-percentile-tooltip-text-percentile {
color: var(--text-sakura-500);
font-variant-numeric: lining-nums tabular-nums stacked-fractions slashed-zero;
font-feature-settings:
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 12px;
}
.span-percentile-tooltip-text-link {
color: var(--l2-foreground);
text-align: right;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
}
.span-details-drawer-docked {
width: 48px;
flex: 0 48px !important;
.header {
justify-content: center;
}
}
.resizable-handle {
box-sizing: border-box;
border: 2px solid transparent;
&:hover,
&[data-resize-handle-state='drag'],
&[data-resize-handle-state='hover'] {
border-color: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
}
}
.linked-spans-tab-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
.span-logs {
margin-inline: 16px;
height: 100%;
display: flex;
flex-direction: column;
&-virtuoso {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&-list-container {
flex: 1;
min-height: 0;
.logs-loading-skeleton {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
color: var(--l2-foreground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 0;
}
}
&-empty-content {
height: 100%;
border-top: none;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 96px;
gap: 12px;
.description {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
width: 320px;
.no-data-img {
height: 2rem;
width: 2rem;
}
.no-data-text-1 {
color: var(--l2-foreground);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.no-data-text-2 {
font-weight: 500;
}
}
.action-section {
width: 320px;
.action-btn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l2-foreground);
padding: 4px 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}

View File

@@ -1,99 +0,0 @@
.span-related-signals-drawer {
.ant-drawer-body {
padding: 0;
}
.ant-drawer-header {
border-bottom: 1px solid var(--l1-border);
padding: 16px 15px;
.title {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&__divider {
--divider-vertical-margin: 10px;
}
.ant-drawer-close {
margin: 0 !important;
}
.span-related-signals-drawer__content {
height: 100%;
display: flex;
flex-direction: column;
}
.view-title {
display: flex;
align-items: center;
gap: 8px;
}
.views-tabs-container {
padding: 16px 15px;
display: flex;
align-items: center;
justify-content: space-between;
.open-in-explorer {
display: flex;
align-items: center;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.ant-radio-button-wrapper {
width: 114px;
height: 32px;
.view-title {
gap: 6px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 400;
letter-spacing: -0.06px;
}
}
}
.span-related-signals-drawer__applied-filters {
padding: 11px;
margin-inline: 16px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.span-related-signals-drawer__filters-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.span-related-signals-drawer__filter-tag {
padding: 2px 6px;
border-radius: 2px;
background: var(--l3-background);
cursor: default;
.ant-typography {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.infra-metrics-container {
padding-inline: 16px;
.infra-metrics-card {
border: 1px solid var(--l1-border);
}
}
}

View File

@@ -1,257 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Drawer } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
import { hasInfraMetadata } from '../utils';
import './SpanRelatedSignals.styles.scss';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface SpanRelatedSignalsProps {
selectedSpan: Span;
traceStartTime: number;
traceEndTime: number;
isOpen: boolean;
onClose: () => void;
initialView: RelatedSignalsViews;
}
function SpanRelatedSignals({
selectedSpan,
traceStartTime,
traceEndTime,
isOpen,
onClose,
initialView,
}: SpanRelatedSignalsProps): JSX.Element {
const [selectedView, setSelectedView] =
useState<RelatedSignalsViews>(initialView);
const isDarkMode = useIsDarkMode();
// Extract infrastructure metadata from span attributes
const infraMetadata = useMemo(() => {
// Only return metadata if span has infrastructure metadata
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: selectedSpan.tagMap['k8s.cluster.name'] || '',
podName: selectedSpan.tagMap['k8s.pod.name'] || '',
nodeName: selectedSpan.tagMap['k8s.node.name'] || '',
hostName: selectedSpan.tagMap['host.name'] || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: isOpen,
});
const handleTabChange = useCallback((value: string): void => {
setSelectedView(value as RelatedSignalsViews);
}, []);
const tabOptions = useMemo(() => {
const baseOptions = [
{
label: (
<div className="view-title">
<LogsIcon width={14} height={14} />
Logs
</div>
),
value: RelatedSignalsViews.LOGS,
},
];
// Add Infra option if infrastructure metadata is available
if (infraMetadata) {
baseOptions.push({
label: (
<div className="view-title">
<BarChart size={14} />
Metrics
</div>
),
value: RelatedSignalsViews.INFRA,
});
}
return baseOptions;
}, [infraMetadata]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
getAbsoluteUrl(`${ROUTES.LOGS_EXPLORER}?${searchParams.toString()}`),
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
return (
<Drawer
width="50%"
title={
<>
<Divider
type="vertical"
className="span-related-signals-drawer__divider"
/>
<Typography.Text className="title">
Related Signals - {selectedSpan.name}
</Typography.Text>
</>
}
placement="right"
onClose={onClose}
open={isOpen}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="span-related-signals-drawer"
destroyOnClose
closeIcon={<X size={16} />}
>
{selectedSpan && (
<div className="span-related-signals-drawer__content">
<div className="views-tabs-container">
<SignozRadioGroup
value={selectedView}
options={tabOptions}
onChange={handleTabChange}
className="related-signals-radio"
/>
{selectedView === RelatedSignalsViews.LOGS && (
<Button
icon={<Compass size={18} />}
className="open-in-explorer"
onClick={handleExplorerPageRedirect}
data-testid="open-in-explorer-button"
>
Open in Logs Explorer
</Button>
)}
</div>
{selectedView === RelatedSignalsViews.LOGS && (
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
/>
)}
{selectedView === RelatedSignalsViews.INFRA && infraMetadata && (
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
)}
</div>
)}
</Drawer>
);
}
export default SpanRelatedSignals;

View File

@@ -1,240 +0,0 @@
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import AttributeActions from '../Attributes/AttributeActions';
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockPopover = ({
content,
children,
open,
onOpenChange,
...rest
}: any): JSX.Element => (
<div
data-testid="mock-popover-wrapper"
onMouseEnter={(): void => onOpenChange?.(true)}
{...rest}
>
{children}
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
</div>
);
return { ...actual, Popover: MockPopover };
});
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
getAggregateKeys: jest.fn().mockResolvedValue({
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
],
},
}),
}));
const record = { field: 'http.method', value: 'GET' };
describe('AttributeActions (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders core action buttons (pin, filter in/out, more)', async () => {
render(<AttributeActions record={record} isPinned={false} />);
expect(
screen.getByRole('button', { name: 'Pin attribute' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter for value' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter out value' }),
).toBeInTheDocument();
// more actions (ellipsis) button
expect(screen.getByTestId('attribute-actions-more')).toBeInTheDocument();
});
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
await userEvent.click(filterForBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
await userEvent.click(filterOutBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
expect(ellipsisBtn).toBeInTheDocument();
// hover to trigger Popover open via mock
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
// content appears
await waitFor(() =>
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
);
await userEvent.click(screen.getByText('Group By Attribute'));
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({ key: 'http.method' }),
]),
}),
]),
}),
}),
{},
expect.any(String),
);
});
// After clicking group by, popover should close
await waitFor(() =>
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
);
});
it('hides pin button when showPinned=false', async () => {
render(<AttributeActions record={record} showPinned={false} />);
expect(
screen.queryByRole('button', { name: /pin attribute/i }),
).not.toBeInTheDocument();
});
it('hides copy options when showCopyOptions=false', async () => {
render(<AttributeActions record={record} showCopyOptions={false} />);
const ellipsisBtn = screen.getByTestId('attribute-actions-more');
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
await waitFor(() =>
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
);
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
});
});

View File

@@ -1,383 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { AppProvider } from 'providers/App/App';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock external dependencies
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {
success: jest.fn(),
error: jest.fn(),
};
const mockSetCopy = jest.fn();
const mockQueryClient = {
fetchQuery: jest.fn(),
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock the hooks
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
currentQuery: {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
},
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({ notifications: mockNotifications }),
}));
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): any => [{ value: '' }, mockSetCopy],
}));
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueryClient: (): any => mockQueryClient,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: jest.fn(),
}));
// Mock the API response for getAggregateKeys
const mockAggregateKeysResponse = {
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
isColumn: true,
},
],
},
};
beforeEach(() => {
jest.clearAllMocks();
mockQueryClient.fetchQuery.mockResolvedValue(mockAggregateKeysResponse);
});
// Mock trace data with realistic span attributes
const createMockSpan = (): Span => ({
spanId: '28a8a67365d0bd8b',
traceId: '000000000000000071dc9b0a338729b4',
name: 'HTTP GET /api/users',
timestamp: 1699872000000000,
durationNano: 150000000,
serviceName: 'frontend-service',
spanKind: 'server',
statusCodeString: 'OK',
statusMessage: '',
tagMap: {
'http.method': 'GET',
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/users?page=1',
'http.status_code': '200',
'service.name': 'frontend-service',
'span.kind': 'server',
'user.id': '12345',
'request.id': 'req-abc-123',
},
event: [],
references: [],
hasError: false,
rootSpanId: '',
parentSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
});
const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
const user = userEvent.setup();
const component = render(
<MockQueryClientProvider>
<AppProvider>
<MemoryRouter>
<Route>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={span}
traceStartTime={span.timestamp}
traceEndTime={span.timestamp + span.durationNano}
/>
</Route>
</MemoryRouter>
</AppProvider>
</MockQueryClientProvider>,
);
return { ...component, user };
};
describe('AttributeActions User Flow Tests', () => {
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Complete Attribute Actions User Flow', () => {
it('should allow user to interact with span attribute actions from trace detail page', async () => {
renderSpanDetailsDrawer();
// Verify Attributes tab is displayed with table view
expect(screen.getByText('Attributes')).toBeInTheDocument();
// Verify attributes are displayed
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.getByText('GET')).toBeInTheDocument();
expect(screen.getByText('service.name')).toBeInTheDocument();
expect(screen.getAllByText('frontend-service')[0]).toBeInTheDocument();
// Find an attribute row to test actions on
const httpMethodRow = screen.getByText('http.method').closest('.item');
expect(httpMethodRow).toBeInTheDocument();
// Action buttons are always mounted in the DOM (only CSS-hidden until :hover),
// so we can query them directly without simulating a pointer hover.
const actionButtons = httpMethodRow!.querySelector('.action-btn');
expect(actionButtons).toBeInTheDocument();
const filterForButton = httpMethodRow!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
const filterOutButton = httpMethodRow!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
expect(filterOutButton).toBeInTheDocument();
// Test "Filter for" action — use fireEvent to skip userEvent's pointer
// simulation and the Antd Tooltip mouseEnterDelay timers it triggers.
fireEvent.click(filterForButton);
// Verify navigation to traces explorer with inclusive filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset mock for next test
mockRedirectWithQueryBuilderData.mockClear();
// Test "Filter out" action
fireEvent.click(filterOutButton);
// Verify navigation to traces explorer with exclusive filter
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
dataSource: 'traces',
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Verify more actions button exists (popover functionality is tested in unit tests)
const moreActionsButton = httpMethodRow!
.querySelector('.lucide-ellipsis')
?.closest('button');
expect(moreActionsButton).toBeInTheDocument();
});
});
// Todo: to fixed properly - failing with - due to timeout > 5000ms
describe.skip('Filter Replacement Flow', () => {
it('should replace previous filter when applying multiple filters on same field', async () => {
renderSpanDetailsDrawer();
// Find the http.method attribute row
const httpMethodRow = screen.getByText('http.method').closest('.item');
expect(httpMethodRow).toBeInTheDocument();
// Action buttons are always mounted (CSS-hidden until :hover, which jsdom
// doesn't evaluate), so we can click them directly via fireEvent and skip
// userEvent's pointer simulation + Antd Tooltip mouseEnterDelay timers.
const filterForButton = httpMethodRow!.querySelector(
'[aria-label="Filter for value"]',
) as HTMLElement;
expect(filterForButton).toBeInTheDocument();
fireEvent.click(filterForButton);
// Verify first filter was applied
await waitFor(() => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
ROUTES.TRACES_EXPLORER,
);
});
// Reset and simulate existing filter in current query
mockRedirectWithQueryBuilderData.mockClear();
// Apply second filter on same field (should replace, not accumulate)
const filterOutButton = httpMethodRow!.querySelector(
'[aria-label="Filter out value"]',
) as HTMLElement;
expect(filterOutButton).toBeInTheDocument();
fireEvent.click(filterOutButton);
// Verify the new call contains only the new filter (replacement behavior)
await waitFor(() => {
const lastCall =
mockRedirectWithQueryBuilderData.mock.calls[
mockRedirectWithQueryBuilderData.mock.calls.length - 1
];
const queryData = lastCall[0].builder.queryData[0];
const httpMethodFilters = queryData.filters.items.filter(
(item: any) => item.key.key === 'http.method',
);
// Should have only one filter for http.method (the new one)
expect(httpMethodFilters).toHaveLength(1);
expect(httpMethodFilters[0].op).toBe('!=');
expect(httpMethodFilters[0].value).toBe('GET');
});
});
});
describe('Edge Cases', () => {
it('should handle attributes with special characters and JSON values', async () => {
const spanWithSpecialAttrs = createMockSpan();
spanWithSpecialAttrs.tagMap = {
'request.headers.content-type': 'application/json',
'response.body': '{"status":"success","data":[]}',
'trace.annotation': '"quoted_string_value"',
};
const { user } = renderSpanDetailsDrawer(spanWithSpecialAttrs);
// Test attribute with dashes
expect(screen.getByText('request.headers.content-type')).toBeInTheDocument();
expect(screen.getByText('application/json')).toBeInTheDocument();
// Test JSON value
expect(screen.getByText('response.body')).toBeInTheDocument();
// Test quoted string value - should remove surrounding quotes when copying
const quotedAttrRow = screen.getByText('trace.annotation').closest('.item');
await user.hover(quotedAttrRow!);
const actionButtons = quotedAttrRow!.querySelectorAll('.action-btn button');
const moreActionsButton = actionButtons[actionButtons.length - 1];
await user.hover(moreActionsButton);
await waitFor(() => {
expect(screen.getByText('Copy Field Value')).toBeInTheDocument();
});
const copyFieldValueButton = screen.getByText('Copy Field Value');
fireEvent.click(copyFieldValueButton);
// Verify quotes are stripped from copied value
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('quoted_string_value');
});
});
it('should handle empty attributes gracefully', async () => {
const spanWithNoAttrs = createMockSpan();
spanWithNoAttrs.tagMap = {};
renderSpanDetailsDrawer(spanWithNoAttrs);
// Verify no attributes message is displayed
expect(
screen.getByText('No attributes found for selected span'),
).toBeInTheDocument();
});
});
});

View File

@@ -1,495 +0,0 @@
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
expectedHostOnlyMetadata,
expectedInfraMetadata,
expectedNodeOnlyMetadata,
expectedPodOnlyMetadata,
mockEmptyMetricsResponse,
mockNodeMetricsResponse,
mockPodMetricsResponse,
mockSpanWithHostOnly,
mockSpanWithInfraMetadata,
mockSpanWithNodeOnly,
mockSpanWithoutInfraMetadata,
mockSpanWithPodOnly,
} from './infraMetricsTestData';
// Mock external dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${ROUTES.TRACE_DETAIL}`,
}),
}));
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
}),
}));
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
// Mock uplot to avoid rendering issues
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock GetMetricQueryRange to track API calls
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock generateColor
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: jest.fn().mockReturnValue('#1f77b4'),
}));
// Mock OverlayScrollbar
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function OverlayScrollbar({ children }: any) {
return <div data-testid="overlay-scrollbar">{children}</div>;
},
);
// Mock Virtuoso
jest.mock('react-virtuoso', () => ({
Virtuoso: jest.fn(({ data, itemContent }) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => (
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
)),
}));
// Mock InfraMetrics component for focused testing
jest.mock(
'container/LogDetailedView/InfraMetrics/InfraMetrics',
() =>
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function MockInfraMetrics({
podName,
nodeName,
hostName,
clusterName,
timestamp,
dataSource,
}: any) {
return (
<div data-testid="infra-metrics">
<div data-testid="infra-pod-name">{podName}</div>
<div data-testid="infra-node-name">{nodeName}</div>
<div data-testid="infra-host-name">{hostName}</div>
<div data-testid="infra-cluster-name">{clusterName}</div>
<div data-testid="infra-timestamp">{timestamp}</div>
<div data-testid="infra-data-source">{dataSource}</div>
</div>
);
},
);
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({ children }: any): JSX.Element => (
<div>{children}</div>
),
}));
describe('SpanDetailsDrawer - Infra Metrics', () => {
// eslint-disable-next-line sonarjs/no-unused-collection
let apiCallHistory: any[] = [];
beforeEach(() => {
jest.clearAllMocks();
apiCallHistory = [];
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup API call tracking for infra metrics
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);
// Return mock responses for different query types
if (
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
(item: any) => item.key?.key === 'k8s_pod_name',
)
) {
return Promise.resolve(mockPodMetricsResponse);
}
if (
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
(item: any) => item.key?.key === 'k8s_node_name',
)
) {
return Promise.resolve(mockNodeMetricsResponse);
}
return Promise.resolve(mockEmptyMetricsResponse);
});
});
afterEach(() => {
server.resetHandlers();
});
// Mock QueryBuilder context value
const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { items: [], op: 'AND' },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
const renderSpanDetailsDrawer = (props = {}): void => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithInfraMetadata}
traceStartTime={1640995200000} // 2022-01-01 00:00:00
traceEndTime={1640995260000} // 2022-01-01 00:01:00
{...props}
/>
</QueryBuilderContext.Provider>,
);
};
it('should detect infra metadata from span attributes', async () => {
renderSpanDetailsDrawer();
// Click on metrics tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
expect(infraMetricsButton).toBeInTheDocument();
fireEvent.click(infraMetricsButton);
// Wait for infra metrics to load
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify metadata extraction
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedInfraMetadata.podName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedInfraMetadata.nodeName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedInfraMetadata.hostName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedInfraMetadata.clusterName,
);
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
DataSource.TRACES,
);
});
it('should not show infra tab when span lacks infra metadata', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithoutInfraMetadata}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Should NOT show infra tab, only logs tab
expect(
screen.queryByRole('button', { name: /metrics/i }),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
});
it('should show infra tab when span has infra metadata', async () => {
renderSpanDetailsDrawer();
// Should show both logs and infra tabs
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /logs/i })).toBeInTheDocument();
});
it('should handle pod-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithPodOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify pod-only metadata
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedPodOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedPodOnlyMetadata.clusterName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedPodOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedPodOnlyMetadata.hostName,
);
});
it('should handle node-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithNodeOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify node-only metadata
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedNodeOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedNodeOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedNodeOnlyMetadata.clusterName,
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedNodeOnlyMetadata.hostName,
);
});
it('should handle host-only metadata correctly', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithHostOnly}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify host-only metadata
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
expectedHostOnlyMetadata.hostName,
);
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
expectedHostOnlyMetadata.podName,
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
expectedHostOnlyMetadata.nodeName,
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
expectedHostOnlyMetadata.clusterName,
);
});
it('should switch between logs and infra tabs correctly', async () => {
renderSpanDetailsDrawer();
// Initially should show logs tab content
const logsButton = screen.getByRole('button', { name: /logs/i });
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
expect(logsButton).toBeInTheDocument();
expect(infraMetricsButton).toBeInTheDocument();
// Ensure logs tab is active and wait for content to load
fireEvent.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
});
// Click on infra tab
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Should not show logs content anymore
expect(
screen.queryByTestId('open-in-explorer-button'),
).not.toBeInTheDocument();
// Switch back to logs tab
fireEvent.click(logsButton);
// Should not show infra metrics anymore
await waitFor(() => {
expect(screen.queryByTestId('infra-metrics')).not.toBeInTheDocument();
});
// Verify logs content is shown again
await waitFor(() => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
});
});
it('should pass correct data source and handle multiple infra identifiers', async () => {
renderSpanDetailsDrawer();
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
expect(screen.getByRole('button', { name: /metrics/i })).toBeInTheDocument();
// Click on infra tab
const infraMetricsButton = screen.getByRole('button', { name: /metrics/i });
fireEvent.click(infraMetricsButton);
await waitFor(() => {
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
});
// Verify TRACES data source is passed
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
DataSource.TRACES,
);
// All infra identifiers should be passed through
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
'test-pod-abc123',
);
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
'test-node-456',
);
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
'test-host.example.com',
);
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
'test-cluster',
);
});
});

View File

@@ -1,167 +0,0 @@
import { Span } from 'types/api/trace/getTraceV2';
// Constants
const TEST_TRACE_ID = 'test-trace-id';
const TEST_CLUSTER_NAME = 'test-cluster';
const TEST_POD_NAME = 'test-pod-abc123';
const TEST_NODE_NAME = 'test-node-456';
const TEST_HOST_NAME = 'test-host.example.com';
// Mock span with infrastructure metadata (pod + node + host)
export const mockSpanWithInfraMetadata: Span = {
spanId: 'infra-span-id',
traceId: TEST_TRACE_ID,
name: 'api-service',
serviceName: 'api-service',
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
durationNano: 2000000000, // 2 seconds in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
statusMessage: '',
parentSpanId: '',
references: [],
event: [],
tagMap: {
'k8s.cluster.name': TEST_CLUSTER_NAME,
'k8s.pod.name': TEST_POD_NAME,
'k8s.node.name': TEST_NODE_NAME,
'host.name': TEST_HOST_NAME,
'service.name': 'api-service',
'http.method': 'GET',
},
hasError: false,
rootSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
// Mock span with only pod metadata
export const mockSpanWithPodOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'pod-only-span-id',
tagMap: {
'k8s.cluster.name': TEST_CLUSTER_NAME,
'k8s.pod.name': TEST_POD_NAME,
'service.name': 'api-service',
},
};
// Mock span with only node metadata
export const mockSpanWithNodeOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'node-only-span-id',
tagMap: {
'k8s.node.name': TEST_NODE_NAME,
'service.name': 'api-service',
},
};
// Mock span with only host metadata
export const mockSpanWithHostOnly: Span = {
...mockSpanWithInfraMetadata,
spanId: 'host-only-span-id',
tagMap: {
'host.name': TEST_HOST_NAME,
'service.name': 'api-service',
},
};
// Mock span without any infrastructure metadata
export const mockSpanWithoutInfraMetadata: Span = {
...mockSpanWithInfraMetadata,
spanId: 'no-infra-span-id',
tagMap: {
'service.name': 'api-service',
'http.method': 'GET',
'http.status_code': '200',
},
};
// Mock infrastructure metrics API responses
export const mockPodMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
metric: { pod_name: TEST_POD_NAME },
values: [
[1640995200, '0.5'], // CPU usage
[1640995260, '0.6'],
],
},
],
},
},
},
},
};
export const mockNodeMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
metric: { node_name: TEST_NODE_NAME },
values: [
[1640995200, '2.1'], // Memory usage
[1640995260, '2.3'],
],
},
],
},
},
},
},
};
export const mockEmptyMetricsResponse = {
payload: {
data: {
newResult: {
data: {
result: [],
},
},
},
},
};
// Expected infrastructure metadata extractions
export const expectedInfraMetadata = {
clusterName: TEST_CLUSTER_NAME,
podName: TEST_POD_NAME,
nodeName: TEST_NODE_NAME,
hostName: TEST_HOST_NAME,
};
export const expectedPodOnlyMetadata = {
clusterName: TEST_CLUSTER_NAME,
podName: TEST_POD_NAME,
nodeName: '',
hostName: '',
spanTimestamp: '2022-01-01T00:00:00.000Z',
};
export const expectedNodeOnlyMetadata = {
clusterName: '',
podName: '',
nodeName: TEST_NODE_NAME,
hostName: '',
spanTimestamp: '2022-01-01T00:00:00.000Z',
};
export const expectedHostOnlyMetadata = {
clusterName: '',
podName: '',
nodeName: '',
hostName: TEST_HOST_NAME,
spanTimestamp: '2022-01-01T00:00:00.000Z',
};

View File

@@ -1,224 +0,0 @@
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { ILog } from 'types/api/logs/log';
import { Span } from 'types/api/trace/getTraceV2';
// Constants
const TEST_SPAN_ID = 'test-span-id';
const TEST_TRACE_ID = 'test-trace-id';
const TEST_SERVICE = 'test-service';
// Mock span data
export const mockSpan: Span = {
spanId: TEST_SPAN_ID,
traceId: TEST_TRACE_ID,
name: TEST_SERVICE,
serviceName: TEST_SERVICE,
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
durationNano: 1000000000, // 1 second in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
statusMessage: '',
parentSpanId: '',
references: [],
event: [],
tagMap: {
'http.method': 'GET',
[SPAN_ATTRIBUTES.HTTP_URL]: '/api/test',
'http.status_code': '200',
},
hasError: false,
rootSpanId: '',
kind: 0,
rootName: '',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
// Mock span with long status message (> 100 characters) for testing truncation
export const mockSpanWithLongStatusMessage: Span = {
...mockSpan,
statusMessage:
'Error: Connection timeout occurred while trying to reach the database server. The connection pool was exhausted and all retry attempts failed after 30 seconds.',
};
// Mock span with short status message (<= 100 characters)
export const mockSpanWithShortStatusMessage: Span = {
...mockSpan,
statusMessage: 'Connection successful',
};
// Mock logs with proper relationships
export const mockSpanLogs: ILog[] = [
{
id: 'span-log-1',
timestamp: '2022-01-01T00:00:01.000Z',
body: 'Processing request in span',
severity_text: 'INFO',
severity_number: 9,
spanID: TEST_SPAN_ID,
span_id: TEST_SPAN_ID,
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
{
id: 'span-log-2',
timestamp: '2022-01-01T00:00:02.000Z',
body: 'Span operation completed',
severity_text: 'INFO',
severity_number: 9,
spanID: TEST_SPAN_ID,
span_id: TEST_SPAN_ID,
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
];
export const mockContextLogs: ILog[] = [
{
id: 'context-log-before',
timestamp: '2021-12-31T23:59:59.000Z',
body: 'Context log before span',
severity_text: 'INFO',
severity_number: 9,
spanID: 'different-span-id',
span_id: 'different-span-id',
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
{
id: 'context-log-after',
timestamp: '2022-01-01T00:00:03.000Z',
body: 'Context log after span',
severity_text: 'INFO',
severity_number: 9,
spanID: 'another-different-span-id',
span_id: 'another-different-span-id',
date: '',
traceId: TEST_TRACE_ID,
traceFlags: 0,
severityText: '',
severityNumber: 0,
resources_string: {},
scope_string: {},
attributesString: {},
attributes_string: {},
attributesInt: {},
attributesFloat: {},
},
];
// Combined logs in chronological order
export const mockAllLogs: ILog[] = [
mockContextLogs[0], // before
...mockSpanLogs, // span logs
mockContextLogs[1], // after
];
// Mock API responses
export const mockSpanLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: mockSpanLogs.map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockBeforeLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [mockContextLogs[0]].map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockAfterLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [mockContextLogs[1]].map((log) => ({
data: log,
timestamp: log.timestamp,
})),
},
],
},
},
},
},
};
export const mockEmptyLogsResponse = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [],
},
],
},
},
},
},
};
// Expected v5 filter expressions
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;

View File

@@ -1,11 +0,0 @@
export enum RelatedSignalsViews {
LOGS = 'logs',
// METRICS = 'metrics',
INFRA = 'infra',
}
export const RELATED_SIGNALS_VIEW_TYPES = {
LOGS: RelatedSignalsViews.LOGS,
// METRICS: RelatedSignalsViews.METRICS,
INFRA: RelatedSignalsViews.INFRA,
};

View File

@@ -1,24 +0,0 @@
import { Span } from 'types/api/trace/getTraceV2';
/**
* Infrastructure metadata keys that indicate infra signals are available
*/
export const INFRA_METADATA_KEYS = [
'k8s.cluster.name',
'k8s.pod.name',
'k8s.node.name',
'host.name',
] as const;
/**
* Checks if a span has any infrastructure metadata attributes
* @param span - The span to check for infrastructure metadata
* @returns true if the span has at least one infrastructure metadata key, false otherwise
*/
export function hasInfraMetadata(span: Span | undefined): boolean {
if (!span?.tagMap) {
return false;
}
return INFRA_METADATA_KEYS.some((key) => span.tagMap?.[key]);
}

View File

@@ -1,186 +0,0 @@
.trace-metadata {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0px 16px 0px 16px;
.metadata-info {
display: flex;
flex-direction: column;
gap: 10px;
.first-row {
display: flex;
align-items: center;
.previous-btn {
display: flex;
height: 30px;
padding: 6px 8px;
align-items: center;
gap: 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 4px;
box-shadow: none;
}
.trace-name {
display: flex;
padding: 6px 8px;
margin-left: 6px;
align-items: center;
gap: 4px;
border: 1px solid var(--l1-border);
border-radius: 4px 0px 0px 4px;
background: var(--l2-background);
.drafting {
color: var(--l1-foreground);
}
.trace-id {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.trace-id-value {
display: flex;
padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
border: 1px solid var(--l1-border);
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
}
.second-row {
display: flex;
gap: 24px;
.service-entry-info {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.root-span-name {
display: flex;
padding: 2px 8px;
align-items: center;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
}
}
.trace-duration {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.start-time-info {
display: flex;
gap: 6px;
color: var(--l2-foreground);
align-items: center;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
}
.datapoints-info {
display: flex;
gap: 16px;
.separator {
width: 1px;
background: var(--l3-background);
}
.data-point {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
.text {
color: var(--l2-foreground);
text-align: center;
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.value {
color: var(--l1-foreground);
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 28px; /* 140% */
letter-spacing: -0.1px;
text-transform: uppercase;
text-align: right;
}
}
}
}

View File

@@ -1,171 +0,0 @@
import { useMemo } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import history from 'lib/history';
import {
ArrowLeft,
BetweenHorizontalStart,
CalendarClock,
DraftingCompass,
Timer,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import './TraceMetadata.styles.scss';
export interface ITraceMetadataProps {
traceID: string;
rootServiceName: string;
rootSpanName: string;
startTime: number;
duration: number;
totalSpans: number;
totalErrorSpans: number;
notFound: boolean;
isDataLoading: boolean;
}
function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
const {
traceID,
rootServiceName,
rootSpanName,
startTime,
duration,
totalErrorSpans,
totalSpans,
notFound,
isDataLoading,
} = props;
const { timezone } = useTimezone();
const startTimeInMs = useMemo(
() =>
dayjs(startTime * 1e3)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS),
[startTime, timezone.value],
);
const handlePreviousBtnClick = (): void => {
if (window.history.length > 1) {
history.goBack();
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
};
const isOnOldRoute = !!useRouteMatch({
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
});
const location = useLocation();
const handleSwitchToNewView = (): void => {
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
history.replace({
pathname: `/trace/${traceID}`,
search: location.search,
hash: location.hash,
state: location.state,
});
};
return (
<div className="trace-metadata">
<section className="metadata-info">
<div className="first-row">
<Button
variant="solid"
color="secondary"
size="icon"
className="previous-btn"
prefix={<ArrowLeft size={14} />}
onClick={handlePreviousBtnClick}
/>
<div className="trace-name">
<DraftingCompass size={14} className="drafting" />
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
{isOnOldRoute && (
<Button
variant="solid"
color="primary"
size="md"
className="new-view-btn"
onClick={handleSwitchToNewView}
>
Try new experience
</Button>
)}
</div>
{isDataLoading && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizontalStart size={14} />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
<Skeleton.Input active className="skeleton-input" size="small" />
</div>
</div>
)}
{!isDataLoading && !notFound && (
<div className="second-row">
<div className="service-entry-info">
<BetweenHorizontalStart size={14} />
<Typography.Text className="text">{rootServiceName}</Typography.Text>
&#8212;
<Typography.Text className="text root-span-name">
{rootSpanName}
</Typography.Text>
</div>
<div className="trace-duration">
<Tooltip title="Duration of trace">
<Timer size={14} />
</Tooltip>
<Typography.Text className="text">
{getYAxisFormattedValue(`${duration}`, 'ms')}
</Typography.Text>
</div>
<div className="start-time-info">
<Tooltip title="Start timestamp">
<CalendarClock size={14} />
</Tooltip>
<Typography.Text className="text">
{startTimeInMs || 'N/A'}
</Typography.Text>
</div>
</div>
)}
</section>
{!notFound && (
<section className="datapoints-info">
<div className="data-point">
<Typography.Text className="text">Total Spans</Typography.Text>
<Typography.Text className="value">{totalSpans}</Typography.Text>
</div>
<div className="separator" />
<div className="data-point">
<Typography.Text className="text">Error Spans</Typography.Text>
<Typography.Text className="value">{totalErrorSpans}</Typography.Text>
</div>
</section>
)}
</div>
);
}
export default TraceMetadata;

View File

@@ -1,239 +0,0 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--l1-background);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--l1-foreground);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--l2-background);
border-top: 1px solid var(--l1-border);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--l2-foreground);
.ant-btn-icon {
svg {
stroke: var(--l2-foreground);
}
}
}
}
&__discard-button {
background: var(--l1-border);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
padding: 0 16px;
max-height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--l3-background);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--l3-background);
}
input::placeholder {
color: var(--l2-foreground);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l1-border);
border: none;
box-shadow: none;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}

View File

@@ -1,294 +0,0 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Check, Loader, Plus, Search } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { Button, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { isEqual } from 'lodash-es';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import {
FunnelProvider,
useFunnelContext,
} from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import './AddSpanToFunnelModal.styles.scss';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
triggerAutoSave,
showNotifications,
onChangesDetected,
triggerDiscard,
}: {
funnel: FunnelData;
span: Span;
triggerAutoSave: boolean;
showNotifications: boolean;
onChangesDetected: (hasChanges: boolean) => void;
triggerDiscard: boolean;
}): JSX.Element {
const { handleRestoreSteps, steps } = useFunnelContext();
// Track changes between current steps and original steps
useEffect(() => {
const hasChanges = !isEqual(steps, funnel.steps);
if (onChangesDetected) {
onChangesDetected(hasChanges);
}
}, [steps, funnel.steps, onChangesDetected]);
// Handle discard when triggered from parent
useEffect(() => {
if (triggerDiscard && funnel.steps) {
handleRestoreSteps(funnel.steps);
}
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
isSpanDetailsPage
/>
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList();
const filteredData = useMemo(
() =>
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const handleSaveFunnel = (): void => {
setTriggerSave(true);
// Reset trigger after a brief moment to allow the save to be processed
setTimeout(() => {
setTriggerSave(false);
onClose();
}, 100);
};
const handleDiscard = (): void => {
setTriggerDiscard(true);
// Reset trigger after a brief moment
setTimeout(() => {
setTriggerDiscard(false);
onClose();
}, 100);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<Loader className="animate-spin" size="md" />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
triggerAutoSave={triggerSave}
showNotifications
onChangesDetected={setIsUnsavedChanges}
triggerDiscard={triggerDiscard}
/>
</FunnelProvider>
)}
</Spin>
</div>
</div>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
footer={
activeView === ModalView.DETAILS
? [
<Button
type="default"
key="discard"
onClick={handleDiscard}
className="add-span-to-funnel-modal__discard-button"
disabled={!isUnsavedChanges}
>
Discard
</Button>,
<Button
key="save"
type="primary"
className="add-span-to-funnel-modal__save-button"
onClick={handleSaveFunnel}
disabled={!isUnsavedChanges}
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
>
Save Funnel
</Button>,
]
: [
<Button
key="create"
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>,
]
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -1,28 +0,0 @@
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
.ant-btn-default {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
&.active-tab {
background-color: var(--l1-border);
}
}
.copy-span-btn {
border-color: var(--l1-border) !important;
}
}

View File

@@ -1,131 +0,0 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
describe('SpanLineActionButtons', () => {
beforeEach(() => {
// Clear mock before each test
jest.clearAllMocks();
});
it('renders copy link button with correct icon', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the button is rendered with an icon
const copyButton = screen.getByRole('button');
expect(copyButton).toBeInTheDocument();
expect(copyButton.querySelector('svg')).toBeInTheDocument();
});
it('calls onSpanCopy when copy button is clicked', () => {
const mockOnSpanCopy = jest.fn();
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: mockOnSpanCopy,
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {
delete: jest.fn(),
set: jest.fn(),
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
};
const mockPathname = '/test-path';
const mockLocation = {
origin: 'http://localhost:3000',
};
// Mock window.location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Mock useCopySpanLink hook
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;
mockSetCopy(link);
},
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called with correct link
expect(mockSetCopy).toHaveBeenCalledWith(
'http://localhost:3000/test-path?spanId=test-span-id',
);
});
});

View File

@@ -1,28 +0,0 @@
import { Link } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanLineActionButtons.styles.scss';
export interface SpanLineActionButtonsProps {
span: Span;
}
export default function SpanLineActionButtons({
span,
}: SpanLineActionButtonsProps): JSX.Element {
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className="span-line-action-buttons">
<Tooltip title="Copy Span Link">
<Button
size="small"
icon={<Link size={14} />}
onClick={onSpanCopy}
className="copy-span-btn"
/>
</Tooltip>
</div>
);
}

View File

@@ -1,9 +0,0 @@
.trace-waterfall {
height: calc(70vh - 236px);
.loading-skeleton {
justify-content: center;
align-items: center;
padding: 20px;
}
}

View File

@@ -1,137 +0,0 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;
errorFetchingTraceData: unknown;
interestedSpanId: IInterestedSpan;
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
const {
traceData,
isFetchingTraceData,
errorFetchingTraceData,
interestedSpanId,
traceId,
uncollapsedNodes,
setInterestedSpanId,
setTraceFlamegraphStatsWidth,
setSelectedSpan,
selectedSpan,
} = props;
// get the current state of trace waterfall based on the API lifecycle
const traceWaterfallState = useMemo(() => {
if (isFetchingTraceData) {
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length > 0
) {
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceWaterfallStates.LOADING;
}
if (errorFetchingTraceData) {
return TraceWaterfallStates.ERROR;
}
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length === 0
) {
return TraceWaterfallStates.NO_DATA;
}
return TraceWaterfallStates.SUCCESS;
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(
() => traceData?.payload?.spans || [],
[traceData?.payload?.spans],
);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceWaterfallState) {
case TraceWaterfallStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
case TraceWaterfallStates.ERROR:
return <Error error={errorFetchingTraceData as AxiosError} />;
case TraceWaterfallStates.NO_DATA:
return <NoData id={traceId} />;
case TraceWaterfallStates.SUCCESS:
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
traceMetadata={{
traceId,
startTime: traceData?.payload?.startTimestampMillis || 0,
endTime: traceData?.payload?.endTimestampMillis || 0,
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
}}
interestedSpanId={interestedSpanId || ''}
uncollapsedNodes={uncollapsedNodes}
setInterestedSpanId={setInterestedSpanId}
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
errorFetchingTraceData,
interestedSpanId,
selectedSpan,
setInterestedSpanId,
setSelectedSpan,
setTraceFlamegraphStatsWidth,
spans,
traceData?.payload?.endTimestampMillis,
traceData?.payload?.hasMissingSpans,
traceData?.payload?.startTimestampMillis,
traceId,
traceWaterfallState,
uncollapsedNodes,
]);
return <div className="trace-waterfall">{getContent}</div>;
}
export default TraceWaterfall;

View File

@@ -1,30 +0,0 @@
.error-waterfall {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--danger-background);
.text {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
flex-shrink: 0;
}
.value {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}

View File

@@ -1,26 +0,0 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-waterfall">
<Typography.Text className="text">Something went wrong!</Typography.Text>
<Tooltip title={error?.message}>
<Typography.Text className="value" truncate={1}>
{error?.message}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -1,12 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -1,46 +0,0 @@
.filter-row {
display: flex;
align-items: center;
padding: 16px 20px 0px 20px;
gap: 12px;
.query-builder-search-v2 {
width: 100%;
}
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
.ant-typography {
display: flex;
align-items: center;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
}
.no-results {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}

View File

@@ -1,214 +0,0 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
ChevronDown,
ChevronUp,
Loader,
SolidInfoCircle,
} from '@signozhq/icons';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import './Filters.styles.scss';
function prepareQuery(filters: TagFilter, traceID: string): Query {
return {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: TracesAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
filters: {
...filters,
items: [
...filters.items,
{
id: '5ab8e1cf',
key: {
key: 'trace_id',
dataType: DataTypes.String,
type: '',
id: 'trace_id--string----true',
},
op: '=',
value: traceID,
},
],
},
},
],
},
};
}
function Filters({
startTime,
endTime,
traceID,
onFilteredSpansChange = (): void => {},
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
searchParams.set('spanId', filteredSpanIds[index]);
}
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
);
const { isFetching, error } = useGetQueryRange(
{
query: prepareQuery(filters, traceID),
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start: startTime,
end: endTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: {
offset: 0,
limit: 200,
},
selectColumns: [
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
],
},
},
DEFAULT_ENTITY_VERSION,
{
queryKey: [filters],
enabled: filters.items.length > 0,
onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
}
},
},
);
return (
<div className="filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && (
<Spin indicator={<Loader className="animate-spin" />} size="small" />
)}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<SolidInfoCircle size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</div>
);
}
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters;

View File

@@ -1,38 +0,0 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
export const BASE_FILTER_QUERY: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: 200,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
selectColumns: [],
};

View File

@@ -1,416 +0,0 @@
.success-content {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
.missing-spans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: color-mix(in srgb, var(--bg-robin-600) 10%, transparent);
.left-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.right-info {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row-reverse;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.right-info:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.waterfall-table {
height: calc(70vh - 236px);
overflow: auto;
overflow-x: hidden;
padding: 0px 20px 20px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
// default table overrides css for table v3
.div-table {
width: 100% !important;
border: none !important;
}
.div-thead {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--l1-background) !important;
.div-tr {
height: 16px;
}
}
.div-tr {
display: flex;
width: 100%;
align-items: center;
height: 54px;
}
.div-tr:hover {
border-radius: 4px;
background: color-mix(
in srgb,
var(--bg-robin-200) 6%,
transparent
) !important;
.div-td .span-overview .second-row .add-funnel-button {
opacity: 1;
}
.span-overview {
background: unset !important;
.span-overview-content {
background: unset !important;
}
}
}
.div-th,
.div-td {
box-shadow: none;
padding: 0px !important;
}
.div-th {
padding: 2px 4px;
position: relative;
font-weight: bold;
text-align: center;
}
.div-td {
display: flex;
height: 54px;
align-items: center;
overflow: hidden;
.span-overview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
.connector-lines {
display: flex;
}
.span-overview-content {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: flex-start;
gap: 5px;
width: 100%;
background-color: var(--l1-background);
height: 100%;
justify-content: center;
&:not(:first-child) {
.first-row {
width: calc(100% - 28px);
}
}
.first-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 20px;
width: 100%;
.span-det {
display: flex;
gap: 6px;
flex-shrink: 0;
align-items: center;
.collapse-uncollapse-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 4px;
gap: 4px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: none;
height: 20px;
.children-count {
color: var(--l2-foreground);
font-family: 'Space Mono';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.span-name {
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: 0.28px;
}
}
.status-code-container {
display: flex;
padding-right: 10px;
.status-code {
display: flex;
height: 20px;
padding: 3px;
align-items: center;
border-radius: 3px;
}
.success {
border: 1px solid var(--primary-background);
background: var(--primary-background);
}
.error {
border: 1px solid var(--danger-background);
background: var(--danger-background);
}
}
}
.second-row {
display: flex;
align-items: center;
gap: 8px;
height: 18px;
width: 100%;
.service-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
.add-funnel-button {
position: relative;
z-index: 1;
opacity: 0;
display: flex;
align-items: center;
gap: 6px;
transition: opacity 0.1s ease-in-out;
&__separator {
color: var(--l2-foreground);
}
&__button {
display: flex;
align-items: center;
justify-content: center;
}
}
}
}
}
.span-duration {
display: flex;
flex-direction: column;
height: 54px;
position: relative;
width: 100%;
padding-left: 15px;
cursor: pointer;
.span-line {
position: relative;
height: 12px;
top: 35%;
border-radius: 6px;
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 6px;
height: 6px;
background-color: var(--primary-background);
border: 1px solid var(--bg-robin-600);
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--danger-background);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
.span-line-text {
position: relative;
top: 40%;
font-variant-numeric: lining-nums tabular-nums stacked-fractions
slashed-zero;
font-feature-settings:
'case' on,
'cpsp' on,
'dlig' on,
'salt' on;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
}
}
.interested-span,
.selected-non-matching-span {
border-radius: 4px;
background: color-mix(
in srgb,
var(--bg-robin-200) 6%,
transparent
) !important;
.span-overview-content {
background: unset;
}
}
.dimmed-span {
opacity: 0.4;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.span-overview-content,
.span-line-text {
opacity: 0.5;
}
}
}
.div-td + .div-td {
border-left: 1px solid var(--l1-border);
}
.div-th + .div-th {
border-left: 1px solid var(--l1-border);
}
.div-tr .div-th:nth-child(2) {
width: calc(100% - var(--header-span-name-size) * 1px) !important;
}
.div-tr .div-td:nth-child(2) {
width: calc(100% - var(--header-span-name-size) * 1px) !important;
}
.resizer {
width: 10px !important;
position: absolute;
top: 0;
height: calc(70vh - 236px);
right: 0;
width: 2px;
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
cursor: col-resize;
user-select: none;
touch-action: none;
}
.resizer.isResizing {
background: color-mix(in srgb, var(--bg-aqua-500) 20%, transparent);
opacity: 1;
}
@media (hover: hover) {
.resizer {
opacity: 0;
}
*:hover > .resizer {
opacity: 1;
}
}
}
.missing-spans-waterfall-table {
height: calc(70vh - 312px);
}
}
.span-dets {
.related-logs {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: none;
}
}

View File

@@ -1,591 +0,0 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { ColumnDef, createColumnHelper } from '@tanstack/react-table';
import { Virtualizer } from '@tanstack/react-virtual';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import SpanHoverCard from 'components/SpanHoverCard/SpanHoverCard';
import { TableV3 } from 'components/TableV3/TableV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import AddSpanToFunnelModal from 'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal';
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
ChevronRight,
CircleAlert,
Leaf,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import funnelAddUrl from '@/assets/Icons/funnel-add.svg';
import Filters from './Filters/Filters';
import './Success.styles.scss';
// css config
const CONNECTOR_WIDTH = 28;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface ITraceMetadata {
traceId: string;
startTime: number;
endTime: number;
hasMissingSpans: boolean;
}
interface ISuccessProps {
spans: Span[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
uncollapsedNodes: string[];
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
setTraceFlamegraphStatsWidth: Dispatch<SetStateAction<number>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function SpanOverview({
span,
isSpanCollapsed,
handleCollapseUncollapse,
handleSpanClick,
handleAddSpanToFunnel,
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
}: {
span: Span;
isSpanCollapsed: boolean;
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { hasEditPermission } = useAppContext();
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = `var(--danger-background)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
style={{
paddingLeft: `${
isRootSpan
? span.level * CONNECTOR_WIDTH
: (span.level - 1) * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH)
}px`,
backgroundImage: `url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="54"><line x1="0" y1="0" x2="0" y2="54" stroke="rgb(29 33 45)" stroke-width="1" /></svg>')`,
backgroundRepeat: 'repeat',
backgroundSize: `${CONNECTOR_WIDTH + 1}px 54px`,
}}
onClick={(): void => handleSpanClick(span)}
>
{!isRootSpan && (
<div className="connector-lines">
<div
style={{
width: `${CONNECTOR_WIDTH}px`,
height: '1px',
borderTop: '1px solid var(--bg-slate-400)',
display: 'flex',
flexShrink: 0,
position: 'relative',
top: '-10px',
}}
/>
</div>
)}
<div className="span-overview-content">
<section className="first-row">
<div className="span-det">
{span.hasChildren ? (
<Button
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
}}
className="collapse-uncollapse-button"
>
{isSpanCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
<Typography.Text className="children-count">
{span.subTreeNodeCount}
</Typography.Text>
</Button>
) : (
<Button className="collapse-uncollapse-button">
<Leaf size={14} />
</Button>
)}
<Typography.Text className="span-name">{span.name}</Typography.Text>
</div>
<HttpStatusBadge statusCode={span.tagMap?.['http.status_code']} />
</section>
<section className="second-row">
<div style={{ width: '2px', background: color, height: '100%' }} />
<Typography.Text className="service-name">
{span.serviceName}
</Typography.Text>
{!!span.serviceName && !!span.name && (
<div className="add-funnel-button">
<span className="add-funnel-button__separator">·</span>
<Tooltip
title={
!hasEditPermission
? 'You need editor or admin access to add spans to funnels'
: ''
}
>
<Button
type="text"
size="small"
className="add-funnel-button__button"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
handleAddSpanToFunnel(span);
}}
disabled={!hasEditPermission}
icon={
<img
className="add-funnel-button__icon"
src={funnelAddUrl}
alt="funnel-icon"
/>
}
/>
</Tooltip>
</div>
)}
</section>
</div>
</div>
</SpanHoverCard>
);
}
export function SpanDuration({
span,
traceMetadata,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
}: {
span: Span;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
);
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = `var(--danger-background)`;
}
const [hasActionButtons, setHasActionButtons] = useState(false);
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
const handleMouseEnter = (): void => {
setHasActionButtons(true);
};
const handleMouseLeave = (): void => {
setHasActionButtons(false);
};
// Calculate text positioning to handle overflow cases
const textStyle = useMemo(() => {
const spanRightEdge = leftOffset + width;
const textWidthApprox = 8; // Approximate text width in percentage
// If span would cause text overflow, right-align text to span end
if (leftOffset > 100 - textWidthApprox) {
return {
right: `${100 - spanRightEdge}%`,
color,
textAlign: 'right' as const,
};
}
// Default: left-align text to span start
return {
left: `${leftOffset}%`,
color,
};
}, [leftOffset, width, color]);
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(): void => handleSpanClick(span)}
>
<div
className="span-line"
style={{
left: `${leftOffset}%`,
width: `${width}%`,
backgroundColor: color,
position: 'relative',
}}
>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / (span.durationNano / 1e6)) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
const { time, timeUnitName } = convertTimeToRelevantUnit(
eventTimeMs - span.timestamp,
);
return (
<Tooltip
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
title={`${event.name} @ ${toFixed(time, 2)} ${timeUnitName}`}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={{
left: `${clampedOffset}%`,
}}
/>
</Tooltip>
);
})}
</div>
{hasActionButtons && <SpanLineActionButtons span={span} />}
<Typography.Text
className="span-line-text"
truncate={1}
style={textStyle}
>{`${toFixed(time, 2)} ${timeUnitName}`}</Typography.Text>
</div>
</SpanHoverCard>
);
}
// table config
const columnDefHelper = createColumnHelper<Span>();
function getWaterfallColumns({
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[];
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({
id: 'span-name',
header: '',
cell: (props): JSX.Element => (
<SpanOverview
span={props.row.original}
handleCollapseUncollapse={handleCollapseUncollapse}
isSpanCollapsed={!uncollapsedNodes.includes(props.row.original.spanId)}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
handleAddSpanToFunnel={handleAddSpanToFunnel}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
size: 450,
/**
* Note: The TanStack table currently does not support percentage-based column sizing.
* Therefore, we specify both `minSize` and `maxSize` for the "span-name" column to ensure
* that its width remains between 240px and 900px. Setting a `maxSize` here is important
* because the "span-duration" column has column resizing disabled, making it difficult
* to enforce a minimum width for that column. By constraining the "span-name" column,
* we indirectly control the minimum width available for the "span-duration" column.
*/
minSize: 240,
maxSize: 900,
}),
columnDefHelper.display({
id: 'span-duration',
header: () => <div />,
enableResizing: false,
cell: (props): JSX.Element => (
<SpanDuration
span={props.row.original}
traceMetadata={traceMetadata}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
}),
];
return waterfallColumns;
}
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
traceMetadata,
interestedSpanId,
uncollapsedNodes,
setInterestedSpanId,
setTraceFlamegraphStatsWidth,
setSelectedSpan,
selectedSpan,
} = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
},
[setInterestedSpanId],
);
const handleVirtualizerInstanceChanged = (
instance: Virtualizer<HTMLDivElement, Element>,
): void => {
const { range } = instance;
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
// do not trigger the API call
if (spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({ spanId: spans[0].spanId, isUncollapsed: false });
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].spanId,
isUncollapsed: false,
});
}
};
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
useState(false);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: Span): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
}
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);
const columns = useMemo(
() =>
getWaterfallColumns({
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}),
[
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
],
);
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
);
if (idx !== -1) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
}, 400);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans]);
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
<div className="missing-spans">
<section className="left-info">
<CircleAlert size={14} />
<Typography.Text className="text">
This trace has missing spans
</Typography.Text>
</section>
<Button
icon={<ArrowUpRight size={14} />}
className="right-info"
type="text"
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
<Filters
startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/>
<TableV3
columns={columns}
data={spans}
config={{
handleVirtualizerInstanceChanged,
}}
customClassName={cx(
'waterfall-table',
traceMetadata.hasMissingSpans ? 'missing-spans-waterfall-table' : '',
)}
virtualiserRef={virtualizerRef}
setColumnWidths={setTraceFlamegraphStatsWidth}
/>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}
export default Success;

View File

@@ -1,264 +0,0 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/ui/badge', () => ({
...jest.requireActual('@signozhq/ui/badge'),
Badge: jest.fn(),
}));
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1234567000,
endTime: 1234569000,
hasMissingSpans: false,
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock URL query hook
(useUrlQuery as jest.Mock).mockReturnValue({
set: mockUrlQuerySet,
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
});
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockHandleSpanClick}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement);
// Verify handleSpanClick was called with the correct span
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
});
it('shows action buttons on hover', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// Hover over the span
fireEvent.mouseEnter(spanElement);
// Action buttons should now be visible
expect(screen.getByRole('button')).toBeInTheDocument();
// Mouse leave should hide the buttons
fireEvent.mouseLeave(spanElement);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies interested-span class when span is selected', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies highlighted-span class when span matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies dimmed-span class when span does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('dims span when filter is active but no matches found', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
});

View File

@@ -1,447 +0,0 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import Success from '../Success';
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
>;
const mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
// App provider is already handled by test-utils
// React Router is already globally mocked
// Mock complex external dependencies that cause provider issues
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
function SpanHoverCard({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
}
SpanHoverCard.displayName = 'SpanHoverCard';
return SpanHoverCard;
});
// Mock the Filters component that's causing React Query issues
jest.mock('../Filters/Filters', () => {
function Filters(): null {
return null;
}
Filters.displayName = 'Filters';
return Filters;
});
// Mock other potential dependencies
jest.mock(
'container/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
() => {
function AddSpanToFunnelModal(): null {
return null;
}
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
return AddSpanToFunnelModal;
},
);
jest.mock('container/TraceWaterfall/SpanLineActionButtons', () => {
function SpanLineActionButtons(): null {
return null;
}
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
return SpanLineActionButtons;
});
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
function HttpStatusBadge(): null {
return null;
}
HttpStatusBadge.displayName = 'HttpStatusBadge';
return HttpStatusBadge;
});
// Mock other utilities that might cause issues
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (): string => '#1890ff',
}));
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
value: number,
): { time: number; timeUnitName: string } => ({
time: value < 1000 ? value : value / 1000,
timeUnitName: value < 1000 ? 'ms' : 's',
}),
}));
jest.mock('utils/toFixed', () => ({
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
}));
// Create a simplified mock TableV3 that renders the actual column components
jest.mock('components/TableV3/TableV3', () => ({
TableV3: ({
columns,
data,
}: {
columns: unknown[];
data: Span[];
}): JSX.Element => {
// Get the current props from the columns (which contain the current state)
const spanOverviewColumn = columns[0] as {
cell?: (props: any) => JSX.Element;
};
const spanDurationColumn = columns[1] as {
cell?: (props: any) => JSX.Element;
};
return (
<div data-testid="trace-table">
{data.map((row: Span) => {
// Create proper cell props that match what TanStack Table expects
const cellProps = {
row: {
original: row,
getValue: (): Span => row,
getAllCells: (): any[] => [],
getVisibleCells: (): any[] => [],
getUniqueValues: (): any[] => [],
getIsSelected: (): boolean => false,
getIsSomeSelected: (): boolean => false,
getIsAllSelected: (): boolean => false,
getCanSelect: (): boolean => true,
getCanSelectSubRows: (): boolean => true,
getCanSelectAll: (): boolean => true,
toggleSelected: (): void => {},
getToggleSelectedHandler: (): (() => void) => (): void => {},
},
column: { id: 'span-name' },
table: {},
cell: {},
renderValue: (): Span => row,
getValue: (): Span => row,
};
const durationCellProps = {
...cellProps,
column: { id: 'span-duration' },
};
return (
<div key={row.spanId} data-testid={`table-row-${row.spanId}`}>
{/* Render span overview column */}
<div data-testid={`cell-0-${row.spanId}`}>
{spanOverviewColumn?.cell?.(cellProps)}
</div>
{/* Render span duration column */}
<div data-testid={`cell-1-${row.spanId}`}>
{spanDurationColumn?.cell?.(durationCellProps)}
</div>
</div>
);
})}
</div>
);
},
}));
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1679748225000000,
endTime: 1679748226000000,
hasMissingSpans: false,
};
const createMockSpan = (spanId: string, level = 1): Span => ({
spanId,
traceId: 'test-trace-id',
rootSpanId: 'span-1',
parentSpanId: level === 0 ? '' : 'span-1',
name: `Test Span ${spanId}`,
serviceName: 'test-service',
timestamp: mockTraceMetadata.startTime + level * 100000,
durationNano: 50000000,
level,
hasError: false,
kind: 1,
references: [],
tagMap: {},
event: [],
rootName: 'Test Root Span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
});
const mockSpans = [
createMockSpan('span-1', 0),
createMockSpan('span-2', 1),
createMockSpan('span-3', 1),
];
// Shared TestComponent for all tests
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
undefined,
);
return (
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
}
describe('Span Click User Flows', () => {
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
const SPAN_OVERVIEW_CLASS = '.span-overview';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
beforeEach(() => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
});
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Initially URL should not have spanId
expect(mockUrlQuery.get('spanId')).toBeNull();
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click on span-2 to test selection change
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
await user.click(span2DurationElement);
// Wait for the state update and re-render
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Initially both areas should show the same visual selection (first span is auto-selected)
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click span-2 to test selection change
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Now span-2 should be selected, span-1 should not
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
// Check that span-2 is selected
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click second span
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
// Second span should be selected, first should not
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('preserves existing URL parameters when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Pre-populate URL with existing parameters
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
setTraceFlamegraphStatsWidth={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
// Verify navigation was called with all parameters
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -1,7 +0,0 @@
export enum TraceWaterfallStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -1,31 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceFlamegraph from 'api/trace/getTraceFlamegraph';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceFlamegraphPayloadProps,
GetTraceFlamegraphSuccessResponse,
} from 'types/api/trace/getTraceFlamegraph';
const useGetTraceFlamegraph = (
props: GetTraceFlamegraphPayloadProps,
): UseLicense =>
useQuery({
queryFn: () => getTraceFlamegraph(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseLicense = UseQueryResult<
SuccessResponse<GetTraceFlamegraphSuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceFlamegraph;

View File

@@ -1,30 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV2 from 'api/trace/getTraceV2';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV2PayloadProps,
GetTraceV2SuccessResponse,
} from 'types/api/trace/getTraceV2';
const useGetTraceV2 = (props: GetTraceV2PayloadProps): UseLicense =>
useQuery({
queryFn: () => getTraceV2(props),
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseLicense = UseQueryResult<
SuccessResponse<GetTraceV2SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV2;

View File

@@ -1,10 +1,10 @@
import { Redirect, useParams } from 'react-router-dom';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
// preserving the query string and hash.
export default function TraceDetailOldRedirect(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
const { id } = useParams<TraceDetailV3URLProps>();
return (
<Redirect

View File

@@ -1,125 +0,0 @@
.not-found-trace {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
width: 500px;
gap: 24px;
margin: 0 auto;
.description {
display: flex;
flex-direction: column;
gap: 6px;
.not-found-img {
height: 32px;
width: 32px;
}
.not-found-text-1 {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.not-found-text-2 {
color: var(--l1-foreground);
}
}
}
.reasons {
display: flex;
flex-direction: column;
gap: 12px;
.reason-1 {
display: flex;
padding: 12px;
align-items: flex-start;
gap: 12px;
border-radius: 4px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
.construction-img {
height: 16px;
width: 16px;
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.reason-2 {
display: flex;
padding: 12px;
align-items: flex-start;
gap: 12px;
border-radius: 4px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
.broom-img {
height: 16px;
width: 16px;
}
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
}
.none-of-above {
display: flex;
flex-direction: column;
gap: 12px;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.action-btns {
display: flex;
gap: 8px;
.action-btn {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
box-shadow: none;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
}
}
}

View File

@@ -1,158 +0,0 @@
.traces-module-container {
.funnel-icon {
transform: rotate(180deg);
}
.trace-detail-header-actions {
display: flex;
align-items: center;
gap: 8px;
padding-right: 16px;
}
.trace-module {
.ant-tabs-tab {
.tab-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.ant-tabs-tab-active {
.tab-item {
color: var(--l1-foreground);
}
}
.ant-tabs-nav {
margin: 0px;
padding: 0px !important;
}
.ant-tabs-nav::before {
border-bottom: 1px solid var(--l1-border) !important;
}
.ant-tabs-nav-list {
transform: translate(15px, 0px) !important;
}
}
.old-switch {
display: flex;
align-items: center;
color: var(--l2-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px;
}
}
.trace-layout {
display: flex;
height: calc(100vh - 44px);
.trace-left-content {
display: flex;
flex-direction: column;
gap: 25px;
padding-top: 16px;
.flamegraph-waterfall-toggle {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
height: 31px;
color: var(--l2-foreground);
padding: 5px 20px;
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px !important;
}
}
.span-list-toggle {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
height: 31px;
padding: 5px 20px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.ant-btn-icon {
margin-inline-end: 0px !important;
}
}
.trace-visualisation-tabs {
.ant-tabs-tab {
border-radius: 2px 0px 0px 0px;
background: var(--l2-background);
border-radius: 2px 2px 0px 0px;
border: 1px solid var(--l1-border);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
height: 31px;
}
.ant-tabs-tab-active {
background-color: var(--l1-background);
.ant-btn {
color: var(--l1-foreground) !important;
}
}
.ant-tabs-tab + .ant-tabs-tab {
margin: 0px;
border-left: 0px;
}
.ant-tabs-ink-bar {
height: 1px !important;
background: var(--l1-background) !important;
}
.ant-tabs-nav-list {
transform: translate(15px, 0px) !important;
}
.ant-tabs-nav::before {
border-bottom: 1px solid var(--l1-border);
}
.ant-tabs-nav {
margin: 0px;
padding: 0px !important;
}
}
}
}
}

View File

@@ -1,177 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import { Button, Tabs } from 'antd';
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
import cx from 'classnames';
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
import TraceWaterfall, {
IInterestedSpan,
} from 'container/TraceWaterfall/TraceWaterfall';
import useGetTraceV2 from 'hooks/trace/useGetTraceV2';
import useUrlQuery from 'hooks/useUrlQuery';
import { defaultTo } from 'lodash-es';
import { Span, TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import NoData from './NoData/NoData';
import './TraceDetailV2.styles.scss';
function TraceDetailsV2(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV2URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [traceFlamegraphStatsWidth, setTraceFlamegraphStatsWidth] =
useState<number>(450);
const [isSpanDetailsDocked, setIsSpanDetailsDocked] = useState<boolean>(false);
const [selectedSpan, setSelectedSpan] = useState<Span>();
useEffect(() => {
setInterestedSpanId({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
});
}, [urlQuery]);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV2({
traceId,
uncollapsedSpans: uncollapsedNodes,
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
});
useEffect(() => {
if (traceData && traceData.payload && traceData.payload.uncollapsedSpans) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData]);
useEffect(() => {
if (selectedSpan) {
setIsSpanDetailsDocked(false);
}
}, [selectedSpan]);
const noData = useMemo(
() =>
!isFetchingTraceData &&
!errorFetchingTraceData &&
defaultTo(traceData?.payload?.spans?.length, 0) === 0,
[
errorFetchingTraceData,
isFetchingTraceData,
traceData?.payload?.spans?.length,
],
);
useEffect(() => {
if (noData) {
setIsSpanDetailsDocked(true);
}
}, [noData]);
const items = [
{
label: (
<Button
type="text"
icon={<FlamegraphImg />}
className="flamegraph-waterfall-toggle"
>
Flamegraph
</Button>
),
key: 'flamegraph',
children: (
<>
<TraceFlamegraph
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap || {}}
startTime={traceData?.payload?.startTimestampMillis || 0}
endTime={traceData?.payload?.endTimestampMillis || 0}
traceFlamegraphStatsWidth={traceFlamegraphStatsWidth}
selectedSpan={selectedSpan}
/>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
setTraceFlamegraphStatsWidth={setTraceFlamegraphStatsWidth}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
</>
),
},
];
return (
<ResizablePanelGroup
direction="horizontal"
autoSaveId="trace-drawer"
className="trace-layout"
>
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
<TraceMetadata
traceID={traceId}
isDataLoading={isFetchingTraceData}
duration={
(traceData?.payload?.endTimestampMillis || 0) -
(traceData?.payload?.startTimestampMillis || 0)
}
startTime={(traceData?.payload?.startTimestampMillis || 0) / 1e3}
rootServiceName={traceData?.payload?.rootServiceName || ''}
rootSpanName={traceData?.payload?.rootServiceEntryPoint || ''}
totalErrorSpans={traceData?.payload?.totalErrorSpansCount || 0}
totalSpans={traceData?.payload?.totalSpansCount || 0}
notFound={noData}
/>
{!noData ? (
<Tabs items={items} animated className="trace-visualisation-tabs" />
) : (
<NoData />
)}
</ResizablePanel>
<ResizableHandle withHandle className="resizable-handle" />
<ResizablePanel
defaultSize={20}
minSize={20}
maxSize={50}
className={cx('span-details-drawer', {
'span-details-drawer-docked': isSpanDetailsDocked,
})}
>
<SpanDetailsDrawer
isSpanDetailsDocked={isSpanDetailsDocked}
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
selectedSpan={selectedSpan}
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}
export default TraceDetailsV2;

View File

@@ -1,88 +0,0 @@
import { Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { Compass, Cone, TowerControl } from '@signozhq/icons';
import TraceDetailsV2 from './TraceDetailV2';
import './TraceDetailV2.styles.scss';
interface INewTraceDetailProps {
items: {
label: JSX.Element;
key: string;
children: JSX.Element;
}[];
}
function NewTraceDetail(props: INewTraceDetailProps): JSX.Element {
const { items } = props;
return (
<div className="traces-module-container">
<Tabs
items={items}
animated
className="trace-module"
tabBarExtraContent={{
right: (
<div className="trace-detail-header-actions">
<HeaderRightSection
enableShare
enableFeedback
enableAnnouncements={false}
/>
</div>
),
}}
onTabClick={(activeKey): void => {
if (activeKey === 'saved-views') {
history.push(ROUTES.TRACES_SAVE_VIEWS);
}
if (activeKey === 'trace-details') {
history.push(ROUTES.TRACES_EXPLORER);
}
if (activeKey === 'funnels') {
logEvent('Trace Funnels: visited from trace details page', {});
history.push(ROUTES.TRACES_FUNNELS);
}
}}
/>
</div>
);
}
export default function TraceDetailsPage(): JSX.Element {
const items = [
{
label: (
<div className="tab-item">
<Compass size={16} /> Explorer
</div>
),
key: 'trace-details',
children: <TraceDetailsV2 />,
},
{
label: (
<div className="tab-item">
<Cone className="funnel-icon" size={16} /> Funnels
</div>
),
key: 'funnels',
children: <div />,
},
{
label: (
<div className="tab-item">
<TowerControl size={16} /> Views
</div>
),
key: 'saved-views',
children: <div />,
},
];
return <NewTraceDetail items={items} />;
}

View File

@@ -0,0 +1,90 @@
.notFoundTrace {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60vh;
width: 500px;
gap: var(--spacing-12);
margin: 0 auto;
}
.description {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.notFoundImg {
height: 32px;
width: 32px;
}
.notFoundText1 {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.notFoundText2 {
color: var(--l1-foreground);
}
.reasons {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.reason {
display: flex;
padding: var(--spacing-6);
align-items: flex-start;
gap: var(--spacing-6);
border-radius: 4px;
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
.reasonImg {
height: 16px;
width: 16px;
}
.reasonText {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.noneOfAbove {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.noneText {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.actionBtns {
display: flex;
gap: var(--spacing-4);
}
.actionBtn {
width: 160px;
}

View File

@@ -1,4 +1,4 @@
import { Button } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -8,53 +8,59 @@ import broomUrl from '@/assets/Icons/broom.svg';
import constructionUrl from '@/assets/Icons/construction.svg';
import noDataUrl from '@/assets/Icons/no-data.svg';
import './NoData.styles.scss';
import styles from './NoData.module.scss';
function NoData(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
return (
<div className="not-found-trace">
<section className="description">
<img src={noDataUrl} alt="no-data" className="not-found-img" />
<Typography.Text className="not-found-text-1">
<div className={styles.notFoundTrace} data-testid="trace-no-data">
<section className={styles.description}>
<img src={noDataUrl} alt="no-data" className={styles.notFoundImg} />
<Typography.Text className={styles.notFoundText1}>
Uh-oh! We cannot show the selected trace.
<span className="not-found-text-2">
<span className={styles.notFoundText2}>
This can happen in either of the two scenarios -
</span>
</Typography.Text>
</section>
<section className="reasons">
<div className="reason-1">
<img src={constructionUrl} alt="no-data" className="construction-img" />
<Typography.Text className="text">
<section className={styles.reasons}>
<div className={styles.reason}>
<img src={constructionUrl} alt="no-data" className={styles.reasonImg} />
<Typography.Text className={styles.reasonText}>
The trace data has not been rendered on your SigNoz server yet. You can
wait for a bit and refresh this page if this is the case.
</Typography.Text>
</div>
<div className="reason-2">
<img src={broomUrl} alt="no-data" className="broom-img" />
<Typography.Text className="text">
<div className={styles.reason}>
<img src={broomUrl} alt="no-data" className={styles.reasonImg} />
<Typography.Text className={styles.reasonText}>
The trace has been deleted as the data has crossed its retention period.
</Typography.Text>
</div>
</section>
<section className="none-of-above">
<Typography.Text className="text">
<section className={styles.noneOfAbove}>
<Typography.Text className={styles.noneText}>
If you feel the issue is none of the above, please contact support.
</Typography.Text>
<div className="action-btns">
<div className={styles.actionBtns}>
<Button
className="action-btn"
icon={<RefreshCw size={14} />}
variant="outlined"
color="secondary"
className={styles.actionBtn}
prefix={<RefreshCw size={14} />}
onClick={(): void => window.location.reload()}
testId="trace-no-data-refresh-button"
>
Refresh this page
</Button>
<Button
className="action-btn"
icon={<LifeBuoy size={14} />}
variant="outlined"
color="secondary"
className={styles.actionBtn}
prefix={<LifeBuoy size={14} />}
onClick={(): void => handleContactSupport(isCloudUserVal)}
testId="trace-no-data-contact-support-button"
>
Contact Support
</Button>

View File

@@ -148,7 +148,7 @@ function AnalyticsPanel({
className="floating-panel__drag-handle"
/>
<div className={styles.body}>
<div className={styles.body} data-testid="trace-analytics-panel">
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">

View File

@@ -60,7 +60,7 @@ function DockModeSwitcher({
{DOCK_OPTIONS.map((option) => (
<TooltipRoot key={option.value}>
<TooltipTrigger asChild>
<span>
<span data-testid={`dock-mode-${option.value}`}>
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
</span>
</TooltipTrigger>

View File

@@ -0,0 +1,145 @@
.noEvents {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
.eventsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6);
}
.event {
:global(.ant-collapse) {
border: none;
}
:global(.ant-collapse-content) {
border-top: none;
}
:global(.ant-collapse-item) {
border-bottom: 0px;
}
:global(.ant-collapse-content-box) {
border: 1px solid var(--l1-border);
border-top: none;
}
:global(.ant-collapse-header) {
display: flex;
padding: var(--spacing-4) var(--spacing-3);
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 18px;
letter-spacing: -0.07px;
}
:global(.ant-collapse-expand-icon) {
padding-inline-start: 0px;
padding-inline-end: 0px;
}
}
.collapseTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.diamond {
fill: var(--accent-primary);
}
.eventDetails {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.attributeContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.attributeKey {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.timestampContainer {
display: flex;
gap: var(--spacing-2);
align-items: center;
.attributeValue {
display: flex;
padding: 2px var(--spacing-4);
width: fit-content;
align-items: center;
gap: var(--spacing-4);
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
}
.timestampText {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
.wrapper {
display: flex;
padding: 2px var(--spacing-4);
width: fit-content;
max-width: 100%;
align-items: center;
gap: var(--spacing-4);
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
.attributeValue {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
}
.fullView {
max-height: 70vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}

View File

@@ -4,15 +4,15 @@ import { Collapse, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { Diamond } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import NoData from '../NoData/NoData';
import EventAttribute from './components/EventAttribute';
import NoData from './NoData/NoData';
import './Events.styles.scss';
import styles from './Events.module.scss';
interface IEventsTableProps {
span: Span;
span: SpanV3;
startTime: number;
isSearchVisible: boolean;
}
@@ -33,21 +33,20 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
setModalContent(null);
};
const events = span.event;
const events = span.events;
return (
<div className="events-table">
<div>
{events.length === 0 && (
<div className="no-events">
<div className={styles.noEvents}>
<NoData name="events" />
</div>
)}
<div className="events-container">
<div className={styles.eventsContainer}>
{isSearchVisible && events.length > 0 && (
<Input
autoFocus
placeholder="Search for events..."
className="search-input"
value={fieldSearchInput}
onChange={(e): void => setFieldSearchInput(e.target.value)}
/>
@@ -58,7 +57,7 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
)
.map((event) => (
<div
className="event"
className={styles.event}
key={`${event.name} ${JSON.stringify(event.attributeMap)}`}
>
<Collapse
@@ -69,38 +68,36 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
{
key: '1',
label: (
<div className="collapse-title">
<Diamond size={14} className="diamond" />
<Typography.Text className="collapse-title-name">
{event.name}
</Typography.Text>
<div className={styles.collapseTitle}>
<Diamond size={14} className={styles.diamond} />
<Typography.Text>{event.name}</Typography.Text>
</div>
),
children: (
<div className="event-details">
<div className="attribute-container" key="timeUnixNano">
<Typography.Text className="attribute-key">
<div className={styles.eventDetails}>
<div className={styles.attributeContainer} key="timeUnixNano">
<Typography.Text className={styles.attributeKey}>
Start Time
</Typography.Text>
<div className="timestamp-container">
<Typography.Text className="attribute-value">
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(event.timeUnixNano || 0) / 1e6 - startTime}`,
'ms',
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
<Typography.Text className={styles.timestampText}>
since trace start
</Typography.Text>
</div>
<div className="timestamp-container">
<Typography.Text className="attribute-value">
<div className={styles.timestampContainer}>
<Typography.Text className={styles.attributeValue}>
{getYAxisFormattedValue(
`${(event.timeUnixNano || 0) / 1e6 - span.timestamp}`,
'ms',
)}
</Typography.Text>
<Typography.Text className="timestamp-text">
<Typography.Text className={styles.timestampText}>
since span start
</Typography.Text>
</div>
@@ -130,9 +127,7 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
width="80vw"
centered
>
<pre className="attribute-with-expandable-popover__full-view">
{modalContent?.content}
</pre>
<pre className={styles.fullView}>{modalContent?.content}</pre>
</Modal>
</div>
);

View File

@@ -0,0 +1,20 @@
.noData {
display: flex;
gap: var(--spacing-2);
flex-direction: column;
}
.noDataImg {
height: 32px;
width: 32px;
}
.noDataText {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: -0.07px;
}

View File

@@ -2,7 +2,7 @@ import { Typography } from '@signozhq/ui/typography';
import noDataUrl from '@/assets/Icons/no-data.svg';
import './NoData.styles.scss';
import styles from './NoData.module.scss';
interface INoDataProps {
name: string;
@@ -12,9 +12,9 @@ function NoData(props: INoDataProps): JSX.Element {
const { name } = props;
return (
<div className="no-data">
<img src={noDataUrl} alt="no-data" className="no-data-img" />
<Typography.Text className="no-data-text">
<div className={styles.noData}>
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
<Typography.Text className={styles.noDataText}>
No {name} found for selected span
</Typography.Text>
</div>

View File

@@ -0,0 +1,22 @@
.popover {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 50vw;
}
.preview {
max-height: 40vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
padding: var(--spacing-4);
border-radius: 4px;
}
.expandButton {
align-self: flex-end;
display: flex;
align-items: center;
flex-grow: 0;
}

View File

@@ -1,8 +1,11 @@
import { Button, Popover, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Fullscreen } from '@signozhq/icons';
import './AttributeWithExpandablePopover.styles.scss';
import styles from '../Events.module.scss';
import popoverStyles from './AttributeWithExpandablePopover.module.scss';
interface AttributeWithExpandablePopoverProps {
attributeKey: string;
@@ -16,15 +19,13 @@ function AttributeWithExpandablePopover({
onExpand,
}: AttributeWithExpandablePopoverProps): JSX.Element {
const popoverContent = (
<div className="attribute-with-expandable-popover__popover">
<pre className="attribute-with-expandable-popover__preview">
{attributeValue}
</pre>
<div className={popoverStyles.popover}>
<pre className={popoverStyles.preview}>{attributeValue}</pre>
<Button
onClick={(): void => onExpand(attributeKey, attributeValue)}
size="small"
className="attribute-with-expandable-popover__expand-button"
icon={<Fullscreen size={14} />}
size="sm"
className={popoverStyles.expandButton}
prefix={<Fullscreen size={14} />}
>
Expand
</Button>
@@ -32,16 +33,16 @@ function AttributeWithExpandablePopover({
);
return (
<div className="attribute-container" key={attributeKey}>
<div className={styles.attributeContainer} key={attributeKey}>
<Tooltip title={attributeKey}>
<Typography.Text className="attribute-key" truncate={1}>
<Typography.Text className={styles.attributeKey} truncate={1}>
{attributeKey}
</Typography.Text>
</Tooltip>
<div className="wrapper">
<div className={styles.wrapper}>
<Popover content={popoverContent} trigger="hover" placement="topRight">
<Typography.Text className="attribute-value" truncate={1}>
<Typography.Text className={styles.attributeValue} truncate={1}>
{attributeValue}
</Typography.Text>
</Popover>

View File

@@ -1,6 +1,8 @@
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styles from '../Events.module.scss';
import AttributeWithExpandablePopover from './AttributeWithExpandablePopover';
const EXPANDABLE_ATTRIBUTE_KEYS = ['exception.stacktrace', 'exception.message'];
@@ -32,15 +34,15 @@ function EventAttribute({
}
return (
<div className="attribute-container" key={attributeKey}>
<div className={styles.attributeContainer} key={attributeKey}>
<Tooltip title={attributeKey}>
<Typography.Text className="attribute-key" truncate={1}>
<Typography.Text className={styles.attributeKey} truncate={1}>
{attributeKey}
</Typography.Text>
</Tooltip>
<div className="wrapper">
<div className={styles.wrapper}>
<Tooltip title={attributeValue}>
<Typography.Text className="attribute-value" truncate={1}>
<Typography.Text className={styles.attributeValue} truncate={1}>
{attributeValue}
</Typography.Text>
</Tooltip>

View File

@@ -27,9 +27,6 @@ import {
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import {
TraceDetailEventKeys,
@@ -68,6 +65,9 @@ import {
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import Events from './Events/Events';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import styles from './SpanDetailsPanel.module.scss';
@@ -424,9 +424,8 @@ function SpanDetailsContent({
/>
</TabsContent>
<TabsContent value="events">
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
<Events
span={{ ...selectedSpan, event: selectedSpan.events } as any}
span={selectedSpan}
startTime={traceStartTime || 0}
isSearchVisible
/>

View File

@@ -0,0 +1,78 @@
.spanLogs {
margin-inline: var(--spacing-8);
height: 100%;
display: flex;
flex-direction: column;
}
.spanLogsVirtuoso {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
.spanLogsListContainer {
flex: 1;
min-height: 0;
}
.logsLoadingSkeleton {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
color: var(--l2-foreground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-4) 0;
}
.spanLogsEmptyContent {
height: 100%;
border-top: none;
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--spacing-48);
gap: var(--spacing-6);
}
.description {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-6);
width: 320px;
}
.noDataImg {
height: 2rem;
width: 2rem;
}
.noDataText1 {
color: var(--l2-foreground);
font-weight: var(--font-weight-normal);
line-height: 18px;
letter-spacing: -0.07px;
}
.noDataText2 {
font-weight: var(--font-weight-medium);
}
.actionSection {
width: 320px;
}
.actionBtn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-border);
color: var(--l2-foreground);
padding: var(--spacing-2) var(--spacing-4);
font-size: 12px;
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}

View File

@@ -2,7 +2,6 @@ import { useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
@@ -33,7 +32,7 @@ import { v4 as uuid } from 'uuid';
import noDataUrl from '@/assets/Icons/no-data.svg';
import './spanLogs.styles.scss';
import styles from './SpanLogs.module.scss';
interface SpanLogsProps {
traceId: string;
@@ -201,11 +200,13 @@ function SpanLogs({
const renderFooter = useCallback((): JSX.Element | null => {
if (isFetching) {
return <div className="logs-loading-skeleton"> Loading more logs ... </div>;
return (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
);
}
if (hasReachedEndOfLogs) {
return <div className="logs-loading-skeleton"> *** End *** </div>;
return <div className={styles.logsLoadingSkeleton}> *** End *** </div>;
}
return null;
@@ -213,10 +214,10 @@ function SpanLogs({
const renderContent = useMemo(
() => (
<div className="span-logs-list-container">
<div className={styles.spanLogsListContainer}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
className="span-logs-virtuoso"
className={styles.spanLogsVirtuoso}
key="span-logs-virtuoso"
style={{ height: '100%' }}
data={logs}
@@ -234,17 +235,19 @@ function SpanLogs({
);
const renderNoLogsFound = (): JSX.Element => (
<div className="span-logs-empty-content">
<section className="description">
<img src={noDataUrl} alt="no-data" className="no-data-img" />
<Typography.Text className="no-data-text-1">
<div className={styles.spanLogsEmptyContent}>
<section className={styles.description}>
<img src={noDataUrl} alt="no-data" className={styles.noDataImg} />
<Typography.Text className={styles.noDataText1}>
No logs found for selected span.
<span className="no-data-text-2">View logs for the current trace.</span>
<span className={styles.noDataText2}>
View logs for the current trace.
</span>
</Typography.Text>
</section>
<section className="action-section">
<section className={styles.actionSection}>
<Button
className="action-btn"
className={styles.actionBtn}
variant="action"
prefix={<Compass size={14} />}
onClick={handleExplorerPageRedirect}
@@ -281,11 +284,7 @@ function SpanLogs({
return renderContent;
};
return (
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
{renderSpanLogsContent()}
</div>
);
return <div className={styles.spanLogs}>{renderSpanLogsContent()}</div>;
}
SpanLogs.defaultProps = {
emptyStateConfig: undefined,

View File

@@ -64,7 +64,11 @@ export function SpanTooltipContent({
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
{previewRows.map((row) => (
<div key={row.key} className={styles.row}>
<div
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>

View File

@@ -23,7 +23,7 @@ import {
Timer,
} from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
@@ -81,7 +81,7 @@ function TraceDetailsHeader({
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);

View File

@@ -12,6 +12,7 @@ import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
import { useFlamegraphTestHook } from './hooks/useFlamegraphTestHook';
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
import { useScrollToSpan } from './hooks/useScrollToSpan';
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
@@ -159,6 +160,14 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
// E2E-only: expose the live span→rect map so specs can target canvas bars.
// No-op unless window.__SIGNOZ_E2E__ is set (Playwright addInitScript).
useFlamegraphTestHook({
canvasRef,
containerRef,
spanRectsRef,
});
const {
cursorXPercent,
cursorX,

View File

@@ -0,0 +1,101 @@
import { MutableRefObject, useEffect } from 'react';
import { SpanRect } from '../types';
/**
* E2E test hook for the canvas flamegraph. The flamegraph is `<canvas>`, so
* individual bars have no DOM nodes to target — but `spanRectsRef` already
* holds the live span→rectangle map (CSS pixels) used for hit-testing. This
* exposes a thin, read-only view of it on `window.__sigTraceFlame__` so a
* Playwright spec can resolve a span's on-screen point and drive real
* hover/click events at it (see tests/e2e/helpers/flamegraph.ts).
*
* Gated on `window.__SIGNOZ_E2E__` (set by Playwright via addInitScript), so
* nothing is attached in normal runtime — the e2e build is a production build,
* so this must be a RUNTIME flag, not a NODE_ENV/mode check.
*/
interface Point {
x: number;
y: number;
}
interface FlamegraphTestApi {
getSpanPoint: (spanId: string) => Point | null;
isSpanInView: (spanId: string) => boolean;
// Resting group color of a span's bar — changes when colour-by changes.
getSpanColor: (spanId: string) => string | null;
}
declare global {
interface Window {
__SIGNOZ_E2E__?: boolean;
__sigTraceFlame__?: FlamegraphTestApi;
}
}
// Inverse of `getCanvasPointer` in useFlamegraphHover: a CSS-space span rect
// maps back to a viewport point at the bar's center.
function rectToViewportCenter(canvas: HTMLCanvasElement, r: SpanRect): Point {
const box = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const cssWidth = canvas.width / dpr;
const cssHeight = canvas.height / dpr;
const cssX = r.x + r.width / 2;
const cssY = r.y + r.height / 2;
return {
x: box.left + cssX * (box.width / cssWidth),
y: box.top + cssY * (box.height / cssHeight),
};
}
interface UseFlamegraphTestHookParams {
canvasRef: MutableRefObject<HTMLCanvasElement | null>;
containerRef: MutableRefObject<HTMLDivElement | null>;
spanRectsRef: MutableRefObject<SpanRect[]>;
}
export function useFlamegraphTestHook({
canvasRef,
containerRef,
spanRectsRef,
}: UseFlamegraphTestHookParams): void {
useEffect(() => {
if (!window.__SIGNOZ_E2E__) {
return undefined;
}
// Reads `.current` at call time, so it always reflects the latest draw.
const findRect = (spanId: string): SpanRect | undefined =>
spanRectsRef.current.find((r) => r.span.spanId === spanId);
window.__sigTraceFlame__ = {
getSpanPoint: (spanId): Point | null => {
const canvas = canvasRef.current;
const rect = findRect(spanId);
return canvas && rect ? rectToViewportCenter(canvas, rect) : null;
},
isSpanInView: (spanId): boolean => {
const canvas = canvasRef.current;
const container = containerRef.current;
const rect = findRect(spanId);
if (!canvas || !container || !rect) {
return false;
}
const pt = rectToViewportCenter(canvas, rect);
const box = container.getBoundingClientRect();
return (
pt.x >= box.left &&
pt.x <= box.right &&
pt.y >= box.top &&
pt.y <= box.bottom
);
},
getSpanColor: (spanId): string | null => findRect(spanId)?.color ?? null,
};
return (): void => {
delete window.__sigTraceFlame__;
};
}, [canvasRef, containerRef, spanRectsRef]);
}

View File

@@ -28,6 +28,9 @@ export interface SpanRect {
width: number;
height: number;
level: number;
// Resting fill color for the current colour-by grouping. Optional: only the
// draw path sets it; consumers (e.g. the e2e colour-by hook) read it.
color?: string;
}
export interface EventRect {

View File

@@ -279,6 +279,9 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
width,
height: metrics.SPAN_BAR_HEIGHT,
level: levelIndex,
// Resting group color (selected/hovered bars override the fill, but this
// still reflects the colour-by grouping — used by the e2e colour-by hook).
color: isDarkMode ? color : colorDark,
});
span.event?.forEach((event) => {

View File

@@ -72,7 +72,7 @@ function FunnelDetailsView({
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span as any}
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>

View File

@@ -1,35 +1,45 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
const mockSpan: SpanV3 = {
span_id: 'test-span-id',
trace_id: 'test-trace-id',
parent_span_id: 'test-parent-span-id',
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
duration_nano: 1000,
name: 'test-span',
'service.name': 'test-service',
has_error: false,
status_message: 'test-status-message',
status_code: 0,
status_code_string: 'test-status-code-string',
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
kind_string: 'test-span-kind',
has_children: false,
has_sibling: false,
sub_tree_node_count: 0,
level: 0,
attributes: {},
resource: {},
events: [],
http_method: '',
http_url: '',
http_host: '',
db_name: '',
db_operation: '',
external_http_method: '',
external_http_url: '',
response_status_code: '',
is_remote: '',
flags: 0,
trace_state: '',
};
describe('SpanLineActionButtons', () => {
@@ -94,7 +104,7 @@ describe('SpanLineActionButtons', () => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
mockUrlQuery.set('spanId', mockSpan.span_id);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;

View File

@@ -7,12 +7,12 @@ import {
} from '@signozhq/ui/tooltip';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Link } from '@signozhq/icons';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import styles from './SpanLineActionButtons.module.scss';
export interface SpanLineActionButtonsProps {
span: Span;
span: SpanV3;
}
export default function SpanLineActionButtons({
span,

View File

@@ -259,7 +259,10 @@ function Filters({
);
const highlightErrorsToggle = (
<div className={styles.highlightErrorsToggle}>
<div
className={styles.highlightErrorsToggle}
data-testid="highlight-errors-toggle"
>
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"

View File

@@ -246,6 +246,19 @@ const SpanOverview = memo(function SpanOverview({
onAddSpanToFunnel(span);
};
// e2e hook: expose the filter highlight/dim state as a stable attribute, since
// the styles.* classes are hashed at build time and can't be asserted.
let spanState = 'default';
if (isHighlighted) {
spanState = 'highlighted';
} else if (isDimmed) {
spanState = 'dimmed';
} else if (isSelectedNonMatching) {
spanState = 'selected-non-matching';
} else if (isSelected) {
spanState = 'selected';
}
return (
<div
className={cx(styles.spanOverview, {
@@ -254,6 +267,7 @@ const SpanOverview = memo(function SpanOverview({
[styles.isSelectedNonMatching]: isSelectedNonMatching,
[styles.isDimmed]: isDimmed,
})}
data-span-state={spanState}
onClick={(): void => handleSpanClick(span)}
onMouseEnter={(): void => onHoverEnter(span.span_id)}
onMouseLeave={(): void => onHoverLeave()}
@@ -301,6 +315,7 @@ const SpanOverview = memo(function SpanOverview({
{span.has_children && (
<span
className={styles.treeArrow}
data-testid={`cell-collapse-${span.span_id}`}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();

View File

@@ -41,19 +41,6 @@ jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
// React Router is already globally mocked
// Mock complex external dependencies that cause provider issues
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
function SpanHoverCard({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
}
SpanHoverCard.displayName = 'SpanHoverCard';
return SpanHoverCard;
});
// Mock the Filters component that's causing React Query issues
jest.mock('../Filters/Filters', () => {
function Filters(): null {

View File

@@ -11,12 +11,12 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import NoData from './NoData/NoData';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';

View File

@@ -9,7 +9,7 @@ import FunnelItemPopover from 'pages/TracesFunnels/components/FunnelsList/Funnel
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useAppContext } from 'providers/App/App';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FunnelData } from 'types/api/traceFunnels';
import AddFunnelDescriptionModal from './AddFunnelDescriptionModal';
@@ -23,7 +23,7 @@ import './FunnelConfiguration.styles.scss';
interface FunnelConfigurationProps {
funnel: FunnelData;
isTraceDetailsPage?: boolean;
span?: Span;
span?: SpanV3;
triggerAutoSave?: boolean;
showNotifications?: boolean;
}

View File

@@ -4,7 +4,7 @@ import logEvent from 'api/common/logEvent';
import { Plus, Undo2 } from '@signozhq/icons';
import { useFunnelContext } from 'pages/TracesFunnels/FunnelContext';
import { useAppContext } from 'providers/App/App';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import FunnelStep from './FunnelStep';
import InterStepConfig from './InterStepConfig';
@@ -18,7 +18,7 @@ function StepsContent({
span,
}: {
isTraceDetailsPage?: boolean;
span?: Span;
span?: SpanV3;
}): JSX.Element {
const { steps, handleAddStep, handleReplaceStep } = useFunnelContext();
const { hasEditPermission } = useAppContext();
@@ -30,7 +30,7 @@ function StepsContent({
const stepWasAdded = handleAddStep();
if (stepWasAdded) {
handleReplaceStep(steps.length, span.serviceName, span.name);
handleReplaceStep(steps.length, span['service.name'], span.name);
}
logEvent(
'Trace Funnels: span added for a new step from trace details page',
@@ -61,12 +61,12 @@ function StepsContent({
className="funnel-step-wrapper__replace-button"
icon={<Undo2 size={12} />}
disabled={
(step.service_name === span.serviceName &&
(step.service_name === span['service.name'] &&
step.span_name === span.name) ||
!hasEditPermission
}
onClick={(): void =>
handleReplaceStep(index, span.serviceName, span.name)
handleReplaceStep(index, span['service.name'], span.name)
}
>
Replace

View File

@@ -1,70 +0,0 @@
export interface TraceDetailV2URLProps {
id: string;
}
export interface GetTraceV2PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
}
export interface Event {
name: string;
timeUnixNano: number;
attributeMap: Record<string, string>;
isError: boolean;
}
export interface Span {
timestamp: number;
durationNano: number;
spanId: string;
rootSpanId: string;
parentSpanId: string;
traceId: string;
hasError: boolean;
kind: number;
serviceName: string;
name: string;
references: any;
tagMap: Record<string, string>;
event: Event[];
rootName: string;
statusMessage: string;
statusCodeString: string;
spanKind: string;
hasChildren: boolean;
hasSibling: boolean;
subTreeNodeCount: number;
level: number;
// V2 API format fields
attributes_string?: Record<string, string>;
attributes_number?: Record<string, number>;
attributes_bool?: Record<string, boolean>;
resources_string?: Record<string, string>;
// V3 API format fields
attributes?: Record<string, string>;
resources?: Record<string, string>;
http_method?: string;
http_url?: string;
http_host?: string;
db_name?: string;
db_operation?: string;
external_http_method?: string;
external_http_url?: string;
response_status_code?: string;
is_remote?: string;
}
export interface GetTraceV2SuccessResponse {
spans: Span[];
hasMissingSpans: boolean;
uncollapsedSpans: string[];
startTimestampMillis: number;
endTimestampMillis: number;
totalSpansCount: number;
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
serviceNameToTotalDurationMap: Record<string, number>;
}

View File

@@ -0,0 +1,413 @@
import { randomBytes } from 'crypto';
import type { Page } from '@playwright/test';
import largeTraceRecords from '../testdata/traces/large-trace.json';
// ── Seeder: insert traces via POST /telemetry/traces ─────────────────────────
// Shape accepted by the seeder's POST /telemetry/traces endpoint
// (mirrors `Traces.from_dict` in tests/fixtures/traces.py). One object per span;
// spans sharing a `trace_id` form one trace, linked into a tree via
// `parent_span_id`. NOTE: the endpoint does NOT ingest span events/links.
export interface SeederSpan {
timestamp: string; // ISO-8601, e.g. new Date().toISOString()
trace_id: string; // 32 hex chars
span_id: string; // 16 hex chars
parent_span_id?: string; // empty/omitted = root span
name?: string;
kind?: number; // 1=internal 2=server 3=client 4=producer 5=consumer
status_code?: number; // 0=unset 1=ok 2=error
status_message?: string;
duration?: string; // ISO-8601 duration, e.g. "PT0.12S" (default PT1S)
resources?: Record<string, string>; // include 'service.name'
attributes?: Record<string, unknown>;
}
// 16-byte trace id / 8-byte span id, matching tests/fixtures/traces.py.
export const randomTraceId = (): string => randomBytes(16).toString('hex');
export const randomSpanId = (): string => randomBytes(8).toString('hex');
// Seeder base URL written to .env.local by bootstrap/setup.py.
function seederUrl(): string {
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
if (!url) {
throw new Error(
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
);
}
return url;
}
// Insert spans into the backend via the seeder. No auth needed (direct seeder call).
//
// The seeder shares a single ClickHouse client, so concurrent POSTs from
// parallel workers collide with a 500 "concurrent queries within the same
// session". That's transient, so retry with backoff; any other error is real.
export async function seedTracesViaSeeder(
page: Page,
spans: SeederSpan[],
): Promise<void> {
const url = `${seederUrl()}/telemetry/traces`;
const maxAttempts = 6;
let lastStatus = 0;
let lastText = '';
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
const res = await page.request.post(url, {
data: spans,
headers: { 'Content-Type': 'application/json' },
});
if (res.ok()) {
return;
}
lastStatus = res.status();
// eslint-disable-next-line no-await-in-loop
lastText = await res.text();
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
break;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
});
}
throw new Error(`seeder POST /telemetry/traces ${lastStatus}: ${lastText}`);
}
// Truncates ALL seeded traces — only safe at suite teardown, never per-test
// (it would clobber traces other parallel specs seeded). Prefer unique trace
// ids per test + the global teardown, which clears the `traces` signal.
export async function clearTracesViaSeeder(page: Page): Promise<void> {
const res = await page.request.delete(`${seederUrl()}/telemetry/traces`);
if (!res.ok()) {
throw new Error(
`seeder DELETE /telemetry/traces ${res.status()}: ${await res.text()}`,
);
}
}
// ── Navigation ───────────────────────────────────────────────────────────────
// Open a seeded trace and wait until the waterfall has rendered. The trace page
// fetches once on load, so if the seed isn't query-able yet (ClickHouse lag, worse
// under parallel load) it lands on the NoData state and never refetches — this
// reloads until the given row testid appears. Makes seeded-trace specs
// deterministic in the full parallel run, not just when run alone.
export async function gotoTraceUntilLoaded(
page: Page,
url: string,
readyTestId: string,
{ attempts = 5, perAttemptTimeoutMs = 8000 } = {},
): Promise<void> {
// Enable e2e-only test hooks (e.g. the flamegraph span→rect map in
// useFlamegraphTestHook) before the first navigation. Registered here because
// every trace-detail spec loads the page through this helper, so the flag is
// set without a dedicated fixture.
await page.addInitScript(() => {
(window as unknown as { __SIGNOZ_E2E__?: boolean }).__SIGNOZ_E2E__ = true;
});
for (let i = 0; i < attempts; i += 1) {
// eslint-disable-next-line no-await-in-loop
await page.goto(url);
try {
// eslint-disable-next-line no-await-in-loop
await page
.getByTestId(readyTestId)
.waitFor({ state: 'visible', timeout: perAttemptTimeoutMs });
return;
} catch {
// not loaded yet (NoData / seed lag) — reload and retry
}
}
// final navigation so the test's own assertion surfaces a clear failure
await page.goto(url);
}
// ── Trace options menu ─────────────────────────────────────────────────────
// Change the colour-by field via the trace options menu (Trace options → Colour
// by → field). colour-by is a per-user preference that persists, so tests should
// set a known field explicitly rather than assume the default. `fieldName` is a
// COLOR_BY_OPTIONS label (service.name | service.namespace | host.name |
// k8s.node.name | k8s.container.name); exact match avoids service.name matching
// service.namespace.
export async function changeColourByViaMenu(
page: Page,
fieldName: string,
): Promise<void> {
await page.getByRole('button', { name: 'Trace options' }).click();
await page.getByRole('menuitem', { name: /colour by/i }).click();
await page
.getByRole('menuitemradio', { name: fieldName, exact: true })
.click();
}
// ── Large trace fixture (tests/e2e/testdata/traces/large-trace.json) ─────────
// One deep, realistic trace: 100 spans across 18 services, nested ~34 levels,
// 8 error spans, a wide duration spread, and db/http/llm/messaging attributes —
// enough to drive the flamegraph, waterfall, filters and drawer off one seed.
// Converted once from a real getWaterfallV4 capture. `loadLargeTrace()` stamps
// fresh ids per run (parallel isolation), rebases the timeline to ~now, and
// derives landmark span ids so specs target rows without hardcoding ids.
// Shape of each record in large-trace.json.
interface LargeTraceRecord {
span_id: string;
parent_span_id: string; // empty = root
name: string;
kind: number;
status_code: number;
duration: string; // ISO-8601, e.g. "PT0.080000S"
offset_ms: number; // start offset from the root span
resources: Record<string, string>;
attributes: Record<string, unknown>;
}
const LARGE_TRACE_RECORDS = largeTraceRecords as LargeTraceRecord[];
export interface LargeTrace {
traceId: string;
spans: SeederSpan[];
// landmark span ids — already stamped — for targeting rows / the drawer
landmarks: {
root: string;
errors: string[];
db: string;
http: string;
llm: string;
messaging: string;
deepLeaf: string;
};
}
// Depth of a record via its parent chain (the JSON doesn't store level).
function recordDepth(
rec: LargeTraceRecord,
byId: Map<string, LargeTraceRecord>,
): number {
let depth = 0;
let cur: LargeTraceRecord | undefined = rec;
while (cur && cur.parent_span_id) {
cur = byId.get(cur.parent_span_id);
depth += 1;
}
return depth;
}
// Build a seedable copy of the large trace with fresh, isolated ids.
export function loadLargeTrace(): LargeTrace {
const traceId = randomTraceId();
// Stamp a fresh span id for every original id, preserving the tree links.
const idMap = new Map<string, string>();
LARGE_TRACE_RECORDS.forEach((r) => idMap.set(r.span_id, randomSpanId()));
// Sit the whole trace ~1 min in the past so all timestamps stay <= now.
const baseStartMs = Date.now() - 60_000;
const spans: SeederSpan[] = LARGE_TRACE_RECORDS.map((r) => {
const span: SeederSpan = {
timestamp: new Date(baseStartMs + r.offset_ms).toISOString(),
trace_id: traceId,
span_id: idMap.get(r.span_id) as string,
name: r.name,
kind: r.kind,
status_code: r.status_code,
duration: r.duration,
resources: r.resources,
attributes: r.attributes,
};
if (r.parent_span_id) {
span.parent_span_id = idMap.get(r.parent_span_id);
}
return span;
});
const byId = new Map(LARGE_TRACE_RECORDS.map((r) => [r.span_id, r]));
const stamp = (r: LargeTraceRecord | undefined): string =>
r ? (idMap.get(r.span_id) as string) : '';
const firstWithAttr = (key: string): LargeTraceRecord | undefined =>
LARGE_TRACE_RECORDS.find((r) => key in r.attributes);
const deepest = LARGE_TRACE_RECORDS.reduce((a, b) =>
recordDepth(b, byId) > recordDepth(a, byId) ? b : a,
);
const landmarks = {
root: stamp(LARGE_TRACE_RECORDS.find((r) => !r.parent_span_id)),
errors: LARGE_TRACE_RECORDS.filter((r) => r.status_code === 2).map((r) =>
stamp(r),
),
db: stamp(firstWithAttr('db.system')),
http: stamp(firstWithAttr('http.method')),
llm: stamp(firstWithAttr('gen_ai.request.model')),
messaging: stamp(firstWithAttr('messaging.system')),
deepLeaf: stamp(deepest),
};
return { traceId, spans, landmarks };
}
// ── Flamegraph canvas test hook ──────────────────────────────────────────────
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. The
// frontend exposes a read-only span→rect view on window.__sigTraceFlame__
// (useFlamegraphTestHook), present only when __SIGNOZ_E2E__ is set — which
// gotoTraceUntilLoaded injects via addInitScript.
// Mirror of the API exposed by useFlamegraphTestHook.
interface FlamegraphTestApi {
getSpanPoint: (spanId: string) => { x: number; y: number } | null;
isSpanInView: (spanId: string) => boolean;
getSpanColor: (spanId: string) => string | null;
}
interface FlameWindow {
__sigTraceFlame__?: FlamegraphTestApi;
}
// Resolve a span's on-canvas viewport point, waiting through the first paint
// (the hook + spanRects populate only after the flamegraph's draw rAF).
async function spanPoint(
page: Page,
spanId: string,
): Promise<{ x: number; y: number }> {
const handle = await page.waitForFunction(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanPoint(id) ??
null,
spanId,
{ timeout: 10_000 },
);
const point = await handle.jsonValue();
if (!point) {
throw new Error(`flamegraph span "${spanId}" is not drawn on the canvas`);
}
return point;
}
// Hover the flamegraph bar for `spanId` (opens its SpanHoverCard).
export async function hoverFlamegraphSpan(
page: Page,
spanId: string,
): Promise<void> {
const { x, y } = await spanPoint(page, spanId);
await page.mouse.move(x, y);
}
// Click the flamegraph bar for `spanId` (selects the span / opens the drawer).
export async function clickFlamegraphSpan(
page: Page,
spanId: string,
): Promise<void> {
const { x, y } = await spanPoint(page, spanId);
await page.mouse.move(x, y);
await page.mouse.click(x, y);
}
// Whether `spanId`'s bar is currently drawn AND inside the viewport container.
export async function isFlamegraphSpanInView(
page: Page,
spanId: string,
): Promise<boolean> {
return page.evaluate(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.isSpanInView(id) ??
false,
spanId,
);
}
// Resting group color of a span's bar — used to assert colour-by recolor.
export async function getFlamegraphSpanColor(
page: Page,
spanId: string,
): Promise<string | null> {
return page.evaluate(
(id) =>
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanColor(id) ??
null,
spanId,
);
}
// ── User preferences (server-side, per-user) ─────────────────────────────────
// Trace-detail user-preference keys (mirror frontend constants/userPreferences.ts).
export const TRACE_PREFERENCE = {
COLOR_BY: 'span_details_color_by_attribute',
PREVIEW_FIELDS: 'span_details_preview_attributes',
PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
} as const;
// A telemetry field key as persisted in the preview-fields preference. Only
// `name` is required by the store (derivePreviewFields), but fieldContext /
// fieldDataType match how the UI persists them.
export interface PreviewFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
// Read the app JWT from the context's stored auth state — no navigation needed,
// since the authedPage fixture already loaded the admin storageState (localStorage
// AUTH_TOKEN). Preferences are server-side, so page.request doesn't carry the
// Bearer header automatically (auth is JWT-in-localStorage, not cookies).
async function authToken(page: Page): Promise<string> {
const state = await page.context().storageState();
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
if (entry) {
return entry.value;
}
}
throw new Error('AUTH_TOKEN not found in storage state — is the page authed?');
}
// PUT a single user preference (server-side, per-user). Call BEFORE navigating
// to the trace page so its on-mount preference fetch returns the seeded value.
//
// NOTE: user preferences are GLOBAL PER USER, not per-test — they persist on the
// server for the admin user. Reset them (resetTracePreferences) in afterAll, and
// be aware other specs run by the same user in parallel share this state.
export async function setUserPreference(
page: Page,
name: string,
value: unknown,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.put(`/api/v1/user/preferences/${name}`, {
data: { value },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /user/preferences/${name} ${res.status()}: ${await res.text()}`,
);
}
}
// Persist the flamegraph color-by field. `fieldName` must be one of
// COLOR_BY_OPTIONS (service.name | service.namespace | host.name |
// k8s.node.name | k8s.container.name); '' falls back to the default.
export async function setColorByPreference(
page: Page,
fieldName: string,
): Promise<void> {
await setUserPreference(page, TRACE_PREFERENCE.COLOR_BY, fieldName);
}
// Persist the span-details preview fields (shown as rows in the hover card).
export async function setPreviewFieldsPreference(
page: Page,
fields: PreviewFieldKey[],
): Promise<void> {
await setUserPreference(page, TRACE_PREFERENCE.PREVIEW_FIELDS, fields);
}
// Reset trace-detail prefs to defaults. Run in afterAll so a prefs spec doesn't
// leak color-by / preview-field state into other specs for the same user.
export async function resetTracePreferences(page: Page): Promise<void> {
await setColorByPreference(page, '');
await setPreviewFieldsPreference(page, []);
}

2482
tests/e2e/testdata/traces/large-trace.json vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
import { test, expect } from '../../fixtures/auth';
import {
gotoTraceUntilLoaded,
loadLargeTrace,
seedTracesViaSeeder,
} from '../../helpers/trace-details';
// One shared trace for the whole file, seeded once. Unique ids per run keep this
// isolated from other parallel specs; the global teardown clears the traces signal.
const trace = loadLargeTrace();
test.describe('Trace details — span details drawer', () => {
test.beforeAll(async ({ browser }) => {
// Seed once. The seeder needs no auth, so a plain page is fine here
// (beforeAll can't use the test-scoped authedPage fixture).
const page = await browser.newPage();
await seedTracesViaSeeder(page, trace.spans);
await page.close();
});
test.beforeEach(async ({ authedPage: page }) => {
// open the trace, reloading until the waterfall renders (seed→query lag)
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
test('TC-01 clicking a span selects it and opens the drawer', async ({
authedPage: page,
}) => {
const errorSpan = trace.landmarks.errors[0];
await page.getByTestId(`cell-0-${errorSpan}`).click();
// selection is reflected in the URL...
await expect(page).toHaveURL(new RegExp(`spanId=${errorSpan}`));
// ...and the drawer is open (the Overview tab only exists in the drawer)
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
});
test('TC-02 drawer shows the selected spans attributes', async ({
authedPage: page,
}) => {
// Click the DB span; its attribute value should appear in the overview
// (attribute values render only in the drawer, not the waterfall rows).
await page.getByTestId(`cell-0-${trace.landmarks.db}`).click();
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
await expect(page.getByText('redis', { exact: false })).toBeVisible();
});
test('TC-03 dock-mode switching toggles the drawer between floating and docked', async ({
authedPage: page,
}) => {
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
// Default is docked-right → not a floating panel (no drag handle).
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
// Switch to floating (dialog) → the drag handle appears.
await page.getByTestId('dock-mode-dialog').click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
// Switch to docked-bottom → floating handle gone again.
await page.getByTestId('dock-mode-docked').click();
await expect(page.locator('.floating-panel__drag-handle')).toHaveCount(0);
});
test('TC-04 the floating drawer can be dragged', async ({
authedPage: page,
}) => {
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await page.getByTestId('dock-mode-dialog').click();
const handle = page.locator('.floating-panel__drag-handle');
await expect(handle).toBeVisible();
const zero = { x: 0, y: 0, width: 0, height: 0 };
const before = (await handle.boundingBox()) ?? zero;
// Drag from the left of the header (title area) to avoid the action buttons.
const startX = before.x + 30;
const startY = before.y + before.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX - 120, startY + 80, { steps: 8 });
await page.mouse.up();
await expect
.poll(async () => Math.round(((await handle.boundingBox()) ?? before).x))
.toBeLessThan(Math.round(before.x));
});
test('TC-05 a dock-mode change persists and is restored on reload', async ({
authedPage: page,
}) => {
// §0 prefs-boot, UI-first: switch to floating via the dock-mode UI (which
// persists the variant), then reload and confirm it's restored — the drawer
// boots floating, not the docked-right default.
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await page.getByTestId('dock-mode-dialog').click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
await page.reload();
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
});
});

View File

@@ -0,0 +1,52 @@
import { test, expect } from '../../fixtures/auth';
import {
gotoTraceUntilLoaded,
loadLargeTrace,
seedTracesViaSeeder,
} from '../../helpers/trace-details';
// §4 filters — scoped to the highlight-errors switch (the other filter controls,
// query input + category toggles + result nav, are covered by unit tests).
// Toggling it ANDs `has_error = true` into the filter and runs it, so error spans
// match (highlighted) and everything else dims. Each row exposes its filter state
// via data-span-state (the styles.* classes are hashed at build time).
//
// Asserted by state presence, not by specific span ids: running the filter
// auto-selects the first match and scrolls the (virtualized) waterfall to it, so
// which rows are in the DOM shifts. Flamegraph dim is canvas — waterfall only.
const trace = loadLargeTrace();
test.describe('Trace details — highlight errors', () => {
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
await seedTracesViaSeeder(page, trace.spans);
await page.close();
});
test.beforeEach(async ({ authedPage: page }) => {
await gotoTraceUntilLoaded(
page,
`/trace/${trace.traceId}`,
`cell-0-${trace.landmarks.root}`,
);
});
test('TC-01 toggling highlight-errors highlights matches and dims the rest', async ({
authedPage: page,
}) => {
// Before: no filter active → no row is highlighted or dimmed.
await expect(page.locator('[data-span-state="highlighted"]')).toHaveCount(0);
await expect(page.locator('[data-span-state="dimmed"]')).toHaveCount(0);
// Toggle the switch inside the highlight-errors control.
await page.getByTestId('highlight-errors-toggle').getByRole('switch').click();
// After: matching (error) rows are highlighted and non-matching rows dim.
await expect(
page.locator('[data-span-state="highlighted"]').first(),
).toBeVisible();
await expect(
page.locator('[data-span-state="dimmed"]').first(),
).toBeVisible();
});
});

Some files were not shown because too many files have changed in this diff Show More