feat: add support for entrypoint spans toggle in top operations table (#8175)

* feat: add support for entrypoint spans toggle in top operations table

* fix: write tests for entry point toggle

* chore: entry point -> entrypoint

* fix: add info icon and tooltip for entrypoint spans toggle

* fix: fix the copy and link for entrypoint toggle in top operations

* chore: update the tooltip text

* Update frontend/src/container/MetricsApplication/TopOperationsTable.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: fix the failing build

* chore: update the entry point spans docs link

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Shaheer Kochai 2025-07-20 15:50:13 +04:30 committed by GitHub
parent 507dc86af2
commit 41661a5e28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 435 additions and 13 deletions

View File

@ -2,13 +2,20 @@ import axios from 'api';
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
const getTopOperations = async (props: Props): Promise<PayloadProps> => {
const response = await axios.post(`/service/top_operations`, {
const endpoint = props.isEntryPoint
? '/service/entry_point_operations'
: '/service/top_operations';
const response = await axios.post(endpoint, {
start: `${props.start}`,
end: `${props.end}`,
service: props.service,
tags: props.selectedTags,
});
if (props.isEntryPoint) {
return response.data.data;
}
return response.data;
};

View File

@ -2,7 +2,7 @@ import getTopOperations from 'api/metrics/getTopOperations';
import TopOperationsTable from 'container/MetricsApplication/TopOperationsTable';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
@ -20,6 +20,8 @@ function TopOperation(): JSX.Element {
}>();
const servicename = decodeURIComponent(encodedServiceName || '');
const [isEntryPoint, setIsEntryPoint] = useState<boolean>(false);
const { queries } = useResourceAttribute();
const selectedTags = useMemo(
() => (convertRawQueriesToTraceSelectedTags(queries) as Tags[]) || [],
@ -27,19 +29,27 @@ function TopOperation(): JSX.Element {
);
const { data, isLoading } = useQuery<PayloadProps>({
queryKey: [minTime, maxTime, servicename, selectedTags],
queryKey: [minTime, maxTime, servicename, selectedTags, isEntryPoint],
queryFn: (): Promise<PayloadProps> =>
getTopOperations({
service: servicename || '',
start: minTime,
end: maxTime,
selectedTags,
isEntryPoint,
}),
});
const topOperationData = data || [];
return <TopOperationsTable data={topOperationData} isLoading={isLoading} />;
return (
<TopOperationsTable
data={topOperationData}
isLoading={isLoading}
isEntryPoint={isEntryPoint}
onEntryPointToggle={setIsEntryPoint}
/>
);
}
export default TopOperation;

View File

@ -1,9 +1,24 @@
.top-operation {
position: relative;
.top-operation--download {
&__controls {
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
top: 15px;
right: 0px;
z-index: 1;
gap: 8px;
}
&__entry-point {
display: flex;
align-items: center;
gap: 6px;
}
&__download {
.ant-btn-icon {
margin: 0 !important;
}
}
}

View File

@ -1,9 +1,10 @@
import './TopOperationsTable.styles.scss';
import { SearchOutlined } from '@ant-design/icons';
import { InputRef, Tooltip, Typography } from 'antd';
import { InputRef, Switch, Tooltip, Typography } from 'antd';
import { ColumnsType, ColumnType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import TextToolTip from 'components/TextToolTip';
import Download from 'container/Download/Download';
import { filterDropdown } from 'container/ServiceApplication/Filter/FilterDropdown';
import useResourceAttribute from 'hooks/useResourceAttribute';
@ -29,6 +30,8 @@ import {
function TopOperationsTable({
data,
isLoading,
isEntryPoint,
onEntryPointToggle,
}: TopOperationsTableProps): JSX.Element {
const searchInput = useRef<InputRef>(null);
const { servicename: encodedServiceName } = useParams<IServiceName>();
@ -174,20 +177,45 @@ function TopOperationsTable({
hideOnSinglePage: true,
};
const entryPointSpanInfo = {
text: 'Shows the spans where requests enter new services for the first time',
url:
'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
urlText: 'Learn more about Entrypoint Spans.',
};
return (
<div className="top-operation">
<div className="top-operation--download">
<Download
data={downloadableData}
isLoading={isLoading}
fileName={`top-operations-${servicename}`}
/>
<div className="top-operation__controls">
<div className="top-operation__download">
<Download
data={downloadableData}
isLoading={isLoading}
fileName={`top-operations-${servicename}`}
/>
</div>
<div className="top-operation__entry-point">
<Switch
checked={isEntryPoint}
onChange={onEntryPointToggle}
size="small"
/>
<span className="top-operation__entry-point-label">Entrypoint Spans</span>
<TextToolTip
text={entryPointSpanInfo.text}
url={entryPointSpanInfo.url}
useFilledIcon={false}
urlText={entryPointSpanInfo.urlText}
/>
</div>
</div>
<ResizeTable
columns={columns}
loading={isLoading}
showHeader
title={(): string => 'Key Operations'}
title={(): string =>
isEntryPoint ? 'Key Entrypoint Operations' : 'Key Operations'
}
tableLayout="fixed"
dataSource={data}
rowKey="name"
@ -209,6 +237,8 @@ export interface TopOperationList {
interface TopOperationsTableProps {
data: TopOperationList[];
isLoading: boolean;
isEntryPoint: boolean;
onEntryPointToggle: (checked: boolean) => void;
}
export default TopOperationsTable;

View File

@ -1,3 +1,6 @@
import { QueryClient } from 'react-query';
import configureStore from 'redux-mock-store';
import { TopOperationList } from '../TopOperationsTable';
interface TopOperation {
@ -17,3 +20,59 @@ export const getTopOperationList = ({
p95: 0,
p99: 0,
} as TopOperationList);
export const defaultApiCallExpectation = {
service: 'test-service',
start: 1640995200000,
end: 1641081600000,
selectedTags: [],
isEntryPoint: false,
};
export const mockStore = configureStore([]);
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
export const mockTopOperationsData: TopOperationList[] = [
{
name: 'GET /api/users',
p50: 1000000,
p95: 2000000,
p99: 3000000,
numCalls: 100,
errorCount: 5,
},
{
name: 'POST /api/orders',
p50: 1500000,
p95: 2500000,
p99: 3500000,
numCalls: 80,
errorCount: 2,
},
];
export const mockEntryPointData: TopOperationList[] = [
{
name: 'GET /api/health',
p50: 500000,
p95: 1000000,
p99: 1500000,
numCalls: 200,
errorCount: 0,
},
];
export const createMockStore = (): any =>
mockStore({
globalTime: {
minTime: 1640995200000,
maxTime: 1641081600000,
},
});

View File

@ -0,0 +1,300 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import {
createMockStore,
defaultApiCallExpectation,
mockEntryPointData,
mockTopOperationsData,
queryClient,
} from '../__mocks__/getTopOperation';
import TopOperation from '../Tabs/Overview/TopOperation';
// Mock dependencies
jest.mock('hooks/useResourceAttribute');
jest.mock('hooks/useResourceAttribute/utils', () => ({
convertRawQueriesToTraceSelectedTags: (): any[] => [],
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { servicename: string } => ({
servicename: encodeURIComponent('test-service'),
}),
}));
// Mock the util functions that TopOperationsTable uses
jest.mock('../Tabs/util', () => ({
useGetAPMToTracesQueries: (): any => ({
builder: {
queryData: [
{
filters: {
items: [],
},
},
],
},
}),
}));
// Mock the resourceAttributesToTracesFilterItems function
jest.mock('container/TraceDetail/utils', () => ({
resourceAttributesToTracesFilterItems: (): any[] => [],
}));
const mockedUseResourceAttribute = useResourceAttribute as jest.MockedFunction<
typeof useResourceAttribute
>;
// Constants
const KEY_OPERATIONS_TEXT = 'Key Operations';
const KEY_ENTRY_POINT_OPERATIONS_TEXT = 'Key Entrypoint Operations';
const ENTRY_POINT_SPANS_TEXT = 'Entrypoint Spans';
const TOP_OPERATIONS_ENDPOINT = 'top_operations';
const ENTRY_POINT_OPERATIONS_ENDPOINT = 'entry_point_operations';
const renderComponent = (store = createMockStore()): any =>
render(
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<TopOperation />
</QueryClientProvider>
</Provider>,
);
// Helper function to wait for initial render and verify basic functionality
const waitForInitialRender = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
};
// Helper function to click toggle and wait for data to load
const clickToggleAndWaitForDataLoad = async (): Promise<HTMLElement> => {
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
return toggleSwitch;
};
describe('TopOperation API Integration', () => {
let apiCalls: { endpoint: string; body: any }[] = [];
beforeEach(() => {
jest.clearAllMocks();
queryClient.clear();
apiCalls = [];
mockedUseResourceAttribute.mockReturnValue({
queries: [],
} as any);
server.use(
rest.post(
'http://localhost/api/v1/service/top_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: TOP_OPERATIONS_ENDPOINT, body });
return res(ctx.status(200), ctx.json(mockTopOperationsData));
},
),
rest.post(
'http://localhost/api/v1/service/entry_point_operations',
async (req, res, ctx) => {
const body = await req.json();
apiCalls.push({ endpoint: ENTRY_POINT_OPERATIONS_ENDPOINT, body });
return res(ctx.status(200), ctx.json({ data: mockEntryPointData }));
},
),
);
});
it('renders with default key operations on initial load', async () => {
renderComponent();
await waitForInitialRender();
// Verify the toggle is present and unchecked
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
it('calls top_operations API on initial render', async () => {
renderComponent();
await waitForInitialRender();
// Wait a bit more for API calls to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that only the top_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
});
});
it('calls entry_point_operations API when toggle is switched to entry point', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Clear previous API calls
apiCalls = [];
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the API call to be captured
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Verify that the entry_point_operations endpoint was called
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
expect(apiCalls[0].body).toEqual({
start: `${defaultApiCallExpectation.start}`,
end: `${defaultApiCallExpectation.end}`,
service: defaultApiCallExpectation.service,
tags: defaultApiCallExpectation.selectedTags,
});
});
it('switches to entry point operations when toggle is clicked', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Find and click the toggle switch
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).not.toBeChecked();
await clickToggleAndWaitForDataLoad();
// Check that the switch is now checked and title updates
expect(toggleSwitch).toBeChecked();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
});
it('calls correct APIs when toggling multiple times', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Wait for initial API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(0);
});
// Should have called top_operations initially
expect(apiCalls).toHaveLength(1);
expect(apiCalls[0].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
// Toggle to entry point
await clickToggleAndWaitForDataLoad();
// Wait for the second API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(1);
});
// Should now have called entry_point_operations
expect(apiCalls).toHaveLength(2);
expect(apiCalls[1].endpoint).toBe(ENTRY_POINT_OPERATIONS_ENDPOINT);
// Toggle back to regular operations
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
// Wait for the third API call
await waitFor(() => {
expect(apiCalls.length).toBeGreaterThan(2);
});
// Should have called top_operations again
expect(apiCalls).toHaveLength(3);
expect(apiCalls[2].endpoint).toBe(TOP_OPERATIONS_ENDPOINT);
expect(toggleSwitch).not.toBeChecked();
});
it('displays entry point toggle with correct label', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText(ENTRY_POINT_SPANS_TEXT)).toBeInTheDocument();
});
const toggleSwitch = screen.getByRole('switch');
expect(toggleSwitch).toBeInTheDocument();
});
it('switches back to key operations when toggle is clicked twice', async () => {
renderComponent();
// Wait for initial render
await waitForInitialRender();
// Toggle on (to entry point)
await clickToggleAndWaitForDataLoad();
expect(screen.getByText(KEY_ENTRY_POINT_OPERATIONS_TEXT)).toBeInTheDocument();
// Toggle off (back to key operations)
const toggleSwitch = screen.getByRole('switch');
act(() => {
fireEvent.click(toggleSwitch);
});
await waitFor(() => {
expect(screen.getByText(KEY_OPERATIONS_TEXT)).toBeInTheDocument();
});
expect(toggleSwitch).not.toBeChecked();
});
});

View File

@ -6,6 +6,7 @@ export interface Props {
start: number;
end: number;
selectedTags: Tags[];
isEntryPoint?: boolean;
}
export type PayloadProps = TopOperationList[];