mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-19 16:40:42 +01:00
Compare commits
4 Commits
refactor/p
...
fix/dynami
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de6fbfeb2d | ||
|
|
6c1f12f5d0 | ||
|
|
6c6632f940 | ||
|
|
39b752c428 |
@@ -1,7 +1,5 @@
|
||||
import { DrawerProps } from 'antd';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
|
||||
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@@ -20,9 +18,7 @@ export type LogDetailProps = {
|
||||
handleOpenInExplorer?: MouseEventHandler;
|
||||
getContainer?: DrawerProps['getContainer'];
|
||||
onApplyLogFilter?: (expression: string) => void;
|
||||
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
|
||||
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
|
||||
Pick<DrawerProps, 'onClose'>;
|
||||
} & Pick<DrawerProps, 'onClose'>;
|
||||
|
||||
export type LogDetailInnerProps = LogDetailProps & {
|
||||
log: NonNullable<LogDetailProps['log']>;
|
||||
|
||||
@@ -46,8 +46,6 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
|
||||
<LogDetail
|
||||
log={mockLog}
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
onAddToQuery={jest.fn()}
|
||||
onClickActionItem={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
|
||||
@@ -25,10 +25,8 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
|
||||
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -82,29 +80,6 @@ function EntityLogsContent({
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
// TODO: Move away from using onAddToQuery after old drawer cleanup
|
||||
const onAddToQuery = useCallback(
|
||||
(fieldKey: string, fieldValue: string, operator: string): void => {
|
||||
handleCloseLogDetail();
|
||||
|
||||
const partExpression = generateFilterQuery({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type: getOldLogsOperatorFromNew(operator),
|
||||
});
|
||||
|
||||
const currentUser = userExpression;
|
||||
const newUser = currentUser.trim()
|
||||
? `${currentUser} AND ${partExpression}`
|
||||
: partExpression;
|
||||
|
||||
querySearchOnRun(newUser);
|
||||
|
||||
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
|
||||
},
|
||||
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
|
||||
);
|
||||
|
||||
const onApplyLogFilter = useCallback(
|
||||
(expression: string): void => {
|
||||
handleCloseLogDetail();
|
||||
@@ -342,8 +317,6 @@ function EntityLogsContent({
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
|
||||
|
||||
@@ -259,8 +259,6 @@ function LiveLogsList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={formattedLogs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
export type AnyObject = { [key: string]: any };
|
||||
|
||||
@@ -19,15 +18,3 @@ export interface DataType {
|
||||
field: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ActionItemProps {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
onClickActionItem: (
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ const normalizeDataType = (
|
||||
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
|
||||
export const getFilterQueryData = (
|
||||
item: IBuilderQuery,
|
||||
target: LogFilterTarget,
|
||||
target: Pick<LogFilterTarget, 'fieldKey' | 'dataType' | 'metricsType'>,
|
||||
value: unknown,
|
||||
operator: string,
|
||||
): IBuilderQuery => {
|
||||
|
||||
@@ -381,8 +381,6 @@ function LogsExplorerList({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
|
||||
@@ -91,13 +91,8 @@ function LogsPanelComponent({
|
||||
() => logs.map((log) => FlatLogData(log) as RowData),
|
||||
[logs],
|
||||
);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const handleRow = useCallback(
|
||||
(record: RowData): HTMLAttributes<RowData> => ({
|
||||
@@ -179,8 +174,6 @@ function LogsPanelComponent({
|
||||
selectedTab={selectedTab}
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
isListViewPanel
|
||||
listViewPanelSelectedFields={widget?.selectedLogFields}
|
||||
logs={logs}
|
||||
|
||||
@@ -10,8 +10,7 @@ import { ILog } from 'types/api/logs/log';
|
||||
import './styles.scss';
|
||||
|
||||
function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog, onAddToQuery } =
|
||||
useActiveLog();
|
||||
const { activeLog, onSetActiveLog, onClearActiveLog } = useActiveLog();
|
||||
|
||||
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
|
||||
|
||||
@@ -45,8 +44,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
|
||||
selectedTab={VIEW_TYPES.OVERVIEW}
|
||||
log={activeLog}
|
||||
onClose={onClearActiveLog}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.collapseIcon {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { rest, type RequestHandler } from 'msw';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import remove from 'api/browser/localstorage/remove';
|
||||
import {
|
||||
type AuthtypesTransactionDTO,
|
||||
@@ -11,11 +12,13 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
type SavedviewtypesUpdatableSavedViewDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { accessFor } from '@/storybook/access/access';
|
||||
import {
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
|
||||
import { respondWith } from '@/storybook/runtime/responseState';
|
||||
|
||||
@@ -43,6 +47,10 @@ import {
|
||||
unreadableViewId,
|
||||
} from './__story_mockdata__/savedViews';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import type { SavedViewRowActionKey } from '../types';
|
||||
|
||||
const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
const LIST = 'Saved Views · list';
|
||||
@@ -216,3 +224,106 @@ export const savedViewsMocks: StoryMocks<typeof controls> = {
|
||||
...definition,
|
||||
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
|
||||
};
|
||||
|
||||
/** The sidebar slot of an explorer without quick filters. */
|
||||
export const sidebarCanvas = withCanvas({
|
||||
width: 280,
|
||||
height: 640,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--l1-border)',
|
||||
});
|
||||
|
||||
/**
|
||||
* The header above the pinned list. `onOpenViews` adds All views, which the
|
||||
* header only shows in an explorer whose quick filters hide the list.
|
||||
*/
|
||||
export function SavedViewsSidebar({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedViewSource;
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<SavedViewsHeader source={source} onOpenViews={onOpenViews} />
|
||||
<SavedViewsPanel source={source} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** The panel fetches the list and checks every row's permissions before it settles. */
|
||||
export const UNTIL_LOADED = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* Re-queried on every attempt: a button whose check settles is a new node, so a
|
||||
* reference taken while it was pending never changes.
|
||||
*/
|
||||
type FindElement = () => HTMLElement;
|
||||
|
||||
export const byTestId =
|
||||
(testId: string): FindElement =>
|
||||
(): HTMLElement =>
|
||||
screen.getByTestId(testId);
|
||||
|
||||
/** A check still in flight: `aria-busy` on a button, `data-loading` on a menu row. */
|
||||
const isPending = (element: HTMLElement): boolean =>
|
||||
element.getAttribute('aria-busy') === 'true' ||
|
||||
element.hasAttribute('data-loading');
|
||||
|
||||
const isDisabled = (element: HTMLElement): boolean =>
|
||||
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
|
||||
|
||||
export const expectDisabled = (
|
||||
find: FindElement,
|
||||
disabled = true,
|
||||
): Promise<void> =>
|
||||
waitFor(async () => {
|
||||
const element = find();
|
||||
|
||||
await expect(isPending(element)).toBe(false);
|
||||
await expect(isDisabled(element)).toBe(disabled);
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
export const expectPending = (find: FindElement): Promise<void> =>
|
||||
waitFor(() => expect(isPending(find())).toBe(true), UNTIL_LOADED);
|
||||
|
||||
export const findRows = (canvasElement: HTMLElement): Promise<HTMLElement[]> =>
|
||||
within(canvasElement).findAllByTestId(
|
||||
'saved-views-row',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
|
||||
export const rowName =
|
||||
(row: HTMLElement): FindElement =>
|
||||
(): HTMLElement =>
|
||||
within(row).getByTestId('saved-views-row-name');
|
||||
|
||||
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const rowMenuItem = (key: SavedViewRowActionKey): FindElement =>
|
||||
byTestId(`saved-views-row-menu-item-${key}`);
|
||||
|
||||
export const openSaveChangesMenu = async (
|
||||
canvasElement: HTMLElement,
|
||||
): Promise<void> => {
|
||||
const trigger = await waitFor(async () => {
|
||||
const button = within(canvasElement).getByTestId('saved-views-save-changes');
|
||||
|
||||
await expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
return button;
|
||||
}, UNTIL_LOADED);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('menu', undefined, UNTIL_LOADED);
|
||||
};
|
||||
|
||||
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
|
||||
byTestId(`saved-views-save-changes-item-${key}`);
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { withCanvas } from '@/storybook/decorators/withCanvas';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsPanel from '../SavedViewsPanel';
|
||||
import { savedViewsMocks } from './SavedViews.stories.mocks';
|
||||
import {
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
} from './SavedViews.stories.mocks';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/** The panel fetches the list and checks every row's permissions before it settles. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
/**
|
||||
* The saved views sidebar of an explorer without quick filters: the header with
|
||||
* the open view and its actions, above the list of views created by the user
|
||||
@@ -28,25 +27,11 @@ const untilLoaded = { timeout: 15_000 };
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views',
|
||||
title: 'Components/Saved Views/Overview',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [
|
||||
withCanvas({
|
||||
width: 280,
|
||||
height: 640,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRight: '1px solid var(--l1-border)',
|
||||
}),
|
||||
],
|
||||
render: ({ source }): JSX.Element => (
|
||||
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
|
||||
<SavedViewsHeader source={source} />
|
||||
<SavedViewsPanel source={source} />
|
||||
</TooltipProvider>
|
||||
),
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
@@ -71,25 +56,15 @@ export const UnsavedChanges: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
};
|
||||
|
||||
/** Without `list` on saved views, the panel shows the permission callout. */
|
||||
export const NoAccess: Story = {
|
||||
args: { revoked: ['list:saved-view'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* The first row's menu. Edit details and Delete follow `update` and `delete` on
|
||||
* that view, so revoking either disables its item.
|
||||
*/
|
||||
export const RowMenu: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const [row] = await within(canvasElement).findAllByTestId(
|
||||
'saved-views-row',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
|
||||
await screen.findByRole('menu', undefined, untilLoaded);
|
||||
await openRowMenu(row);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,15 +75,6 @@ export const RowMenu: Story = {
|
||||
export const SaveChangesMenu: Story = {
|
||||
args: { openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await waitFor(async () => {
|
||||
const button = within(canvasElement).getByTestId('saved-views-save-changes');
|
||||
|
||||
await expect(button).not.toHaveAttribute('aria-busy');
|
||||
|
||||
return button;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await screen.findByRole('menu', undefined, untilLoaded);
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { unreadableViewId } from '../__story_mockdata__/savedViews';
|
||||
import {
|
||||
byTestId,
|
||||
expectDisabled,
|
||||
expectPending,
|
||||
findRows,
|
||||
openRowMenu,
|
||||
openSaveChangesMenu,
|
||||
rowMenuItem,
|
||||
rowName,
|
||||
saveChangesItem,
|
||||
SavedViewsSidebar,
|
||||
savedViewsMocks,
|
||||
sidebarCanvas,
|
||||
UNTIL_LOADED,
|
||||
} from '../SavedViews.stories.mocks';
|
||||
|
||||
import SavedViewsPanel from '../../SavedViewsPanel';
|
||||
|
||||
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
|
||||
|
||||
const componentStory = storyMocks(savedViewsMocks);
|
||||
|
||||
/**
|
||||
* The saved views sidebar with one permission missing at a time. Every story
|
||||
* here is an admin denied exactly what its name says. `list` and `create` are
|
||||
* workspace-wide; `read`, `update` and `delete` are checked per view, so a row
|
||||
* menu only checks its view once it is opened.
|
||||
*
|
||||
* Route: the explorer of the Source control, with the Open view control's
|
||||
* `viewKey`.
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Components/Saved Views/Authz',
|
||||
tags: ['authz', 'play'],
|
||||
component: SavedViewsPanel,
|
||||
decorators: [sidebarCanvas],
|
||||
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
|
||||
...componentStory,
|
||||
parameters: { ...componentStory.parameters },
|
||||
} satisfies Meta<SavedViewsArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<SavedViewsArgs>;
|
||||
|
||||
const LIST = 'list:saved-view';
|
||||
const CREATE = 'create:saved-view';
|
||||
const UPDATE = 'update:saved-view';
|
||||
const DELETE = 'delete:saved-view';
|
||||
|
||||
/**
|
||||
* The panel shows the permission callout in place of the list, and All views,
|
||||
* in an explorer whose quick filters hide the list, is disabled.
|
||||
*/
|
||||
export const NoList: Story = {
|
||||
args: { revoked: [LIST] },
|
||||
render: ({ source }): JSX.Element => (
|
||||
<SavedViewsSidebar source={source} onOpenViews={(): void => {}} />
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectDisabled(byTestId('saved-views-open'));
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(canvas.getByTestId('saved-views-panel')).toHaveTextContent(
|
||||
/is not authorized to perform list/,
|
||||
),
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** An empty workspace with both ways to create a view disabled: + and Create view. */
|
||||
export const NoCreate: Story = {
|
||||
args: { revoked: [CREATE], createdByMe: 0, createdByOthers: 0 },
|
||||
play: async () => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
await expectDisabled(byTestId('saved-views-list-create'));
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Save as new view disabled, Update selected view open. */
|
||||
export const NoCreateSaveMenu: Story = {
|
||||
args: { revoked: [CREATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('save-as-new'));
|
||||
await expectDisabled(saveChangesItem('update'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `read` denied on one view made by someone else: its name is disabled and its
|
||||
* hover card never opens, while every other view still opens.
|
||||
*/
|
||||
export const NoRead: Story = {
|
||||
args: { unreadableView: true },
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const rows = await findRows(canvasElement);
|
||||
const deniedId = unreadableViewId(args.source);
|
||||
|
||||
await Promise.all(
|
||||
rows.map((row) =>
|
||||
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** The unreadable view open from its link: the header names it No view access. */
|
||||
export const NoReadOpenView: Story = {
|
||||
args: { unreadableView: true, openView: 'created-by-others' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
await within(canvasElement).findByText(
|
||||
'No view access',
|
||||
undefined,
|
||||
UNTIL_LOADED,
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Edit details disabled; Copy link, Copy ID and Delete open. */
|
||||
export const NoUpdate: Story = {
|
||||
args: { revoked: [UPDATE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** The ✓ menu on unsaved changes: Update selected view disabled, Save as new view open. */
|
||||
export const NoUpdateSaveMenu: Story = {
|
||||
args: { revoked: [UPDATE], openView: 'unsaved-changes' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await openSaveChangesMenu(canvasElement);
|
||||
await expectDisabled(saveChangesItem('update'));
|
||||
await expectDisabled(saveChangesItem('save-as-new'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/** A row's menu: Delete disabled, Edit details still open. */
|
||||
export const NoDelete: Story = {
|
||||
args: { revoked: [DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything but listing and reading: + is disabled, and a row's menu has only
|
||||
* Copy link and Copy ID left.
|
||||
*/
|
||||
export const ReadOnly: Story = {
|
||||
args: { revoked: [CREATE, UPDATE, DELETE] },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'));
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'));
|
||||
await expectDisabled(rowMenuItem('delete'));
|
||||
await expectDisabled(rowMenuItem('copyLink'), false);
|
||||
await expectDisabled(rowMenuItem('copyId'), false);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check never answering: + spins, and the list stays blank
|
||||
* behind its `list` check.
|
||||
*/
|
||||
export const CheckLoading: Story = {
|
||||
args: { authzState: 'loading' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expectPending(byTestId('saved-views-create'));
|
||||
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The permission check failing rather than denying: the sidebar fails open,
|
||||
* so the list renders and every action stays enabled for the API to refuse.
|
||||
*/
|
||||
export const CheckFailed: Story = {
|
||||
args: { authzState: 'error' },
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expectDisabled(byTestId('saved-views-create'), false);
|
||||
|
||||
const [row] = await findRows(canvasElement);
|
||||
|
||||
await expectDisabled(rowName(row), false);
|
||||
await openRowMenu(row);
|
||||
await expectDisabled(rowMenuItem('editDetails'), false);
|
||||
await expectDisabled(rowMenuItem('delete'), false);
|
||||
},
|
||||
};
|
||||
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
61
frontend/src/hooks/logs/__tests__/useActiveLog.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useActiveLog } from '../useActiveLog';
|
||||
|
||||
const mockRedirect = jest.fn();
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: initialQueriesMap.logs,
|
||||
redirectWithQueryBuilderData: mockRedirect,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
describe('useActiveLog', () => {
|
||||
beforeEach(() => {
|
||||
mockRedirect.mockClear();
|
||||
});
|
||||
|
||||
it('adds the chip field as a filter with its data type and redirects', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery(
|
||||
'service.name',
|
||||
'frontend',
|
||||
'=',
|
||||
DataTypes.String,
|
||||
);
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledTimes(1);
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const item = query.builder.queryData[0].filters?.items.at(-1);
|
||||
expect(item?.key).toMatchObject({
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
});
|
||||
expect(item?.op).toBe('=');
|
||||
expect(item?.value).toBe('frontend');
|
||||
});
|
||||
|
||||
it('keeps the existing filter items ahead of the new one', () => {
|
||||
const { result } = renderHook(() => useActiveLog());
|
||||
|
||||
result.current.onAddToQuery('k8s.pod.name', 'pod-1', '=');
|
||||
|
||||
const query = mockRedirect.mock.calls[0][0] as Query;
|
||||
const items = query.builder.queryData[0].filters?.items ?? [];
|
||||
expect(items).toHaveLength(
|
||||
(initialQueriesMap.logs.builder.queryData[0].filters?.items.length ?? 0) + 1,
|
||||
);
|
||||
expect(items.at(-1)?.key?.key).toBe('k8s.pod.name');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ jest.mock('hooks/useIsTextSelected');
|
||||
const mockOnSetActiveLog = jest.fn();
|
||||
const mockOnClearActiveLog = jest.fn();
|
||||
const mockOnAddToQuery = jest.fn();
|
||||
const mockOnGroupByAttribute = jest.fn();
|
||||
const mockIsTextSelected = jest.fn();
|
||||
|
||||
const mockLog: ILog = {
|
||||
@@ -44,7 +43,6 @@ beforeEach(() => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +78,6 @@ it('should toggle off when clicking the same active log', () => {
|
||||
onSetActiveLog: mockOnSetActiveLog,
|
||||
onClearActiveLog: mockOnClearActiveLog,
|
||||
onAddToQuery: mockOnAddToQuery,
|
||||
onGroupByAttribute: mockOnGroupByAttribute,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useLogDetailHandlers());
|
||||
|
||||
@@ -25,5 +25,4 @@ export type UseActiveLog = {
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
) => void;
|
||||
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { UseActiveLog } from './types';
|
||||
|
||||
export function getOldLogsOperatorFromNew(operator: string): string {
|
||||
switch (operator) {
|
||||
case OPERATORS['=']:
|
||||
return OPERATORS.IN;
|
||||
case OPERATORS['!=']:
|
||||
return OPERATORS.NIN;
|
||||
default:
|
||||
return operator;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export const useActiveLog = (): UseActiveLog => {
|
||||
const queryClient = useQueryClient();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [activeLog, setActiveLog] = useState<ILog | null>(null);
|
||||
|
||||
@@ -58,121 +34,32 @@ export const useActiveLog = (): UseActiveLog => {
|
||||
|
||||
const onClearActiveLog = useCallback((): void => setActiveLog(null), []);
|
||||
|
||||
const onAddToQueryExplorer = useCallback(
|
||||
async (
|
||||
const onAddToQuery = useCallback(
|
||||
(
|
||||
fieldKey: string,
|
||||
fieldValue: string,
|
||||
operator: string,
|
||||
dataType?: DataTypes,
|
||||
fieldType?: MetricsType | undefined,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
): void => {
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) =>
|
||||
getFilterQueryData(item, { fieldKey, dataType }, fieldValue, operator),
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
fieldType,
|
||||
);
|
||||
|
||||
const currentOperator = getOperatorValue(operator);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
filters: {
|
||||
...item.filters,
|
||||
items: [
|
||||
...(item.filters?.items || []),
|
||||
{
|
||||
id: uuid(),
|
||||
key: existAutocompleteKey,
|
||||
op: currentOperator,
|
||||
value: fieldValue,
|
||||
},
|
||||
],
|
||||
op: item.filters?.op || 'AND',
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
const onGroupByAttribute = useCallback(
|
||||
async (fieldKey: string, dataType?: DataTypes): Promise<void> => {
|
||||
try {
|
||||
const keysAutocompleteResponse = await queryClient.fetchQuery(
|
||||
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
async () =>
|
||||
getAggregateKeys({
|
||||
searchText: fieldKey,
|
||||
aggregateOperator:
|
||||
currentQuery.builder.queryData[0].aggregateOperator || '',
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute:
|
||||
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
|
||||
}),
|
||||
);
|
||||
|
||||
const keysAutocomplete: BaseAutocompleteData[] =
|
||||
keysAutocompleteResponse.payload?.attributeKeys || [];
|
||||
|
||||
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
|
||||
keysAutocomplete,
|
||||
fieldKey,
|
||||
dataType,
|
||||
);
|
||||
|
||||
const nextQuery: Query = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: currentQuery.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
groupBy: [...item.groupBy, existAutocompleteKey],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
redirectWithQueryBuilderData(nextQuery);
|
||||
} catch {
|
||||
notifications.error({ message: SOMETHING_WENT_WRONG });
|
||||
}
|
||||
},
|
||||
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
);
|
||||
|
||||
return {
|
||||
activeLog,
|
||||
onSetActiveLog,
|
||||
onClearActiveLog,
|
||||
onAddToQuery: onAddToQueryExplorer,
|
||||
onGroupByAttribute,
|
||||
onAddToQuery,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { QueryOperatorsMultiVal } from 'lib/logql/tokens';
|
||||
|
||||
type Keys = keyof typeof QueryOperatorsMultiVal;
|
||||
type Values = (typeof QueryOperatorsMultiVal)[Keys];
|
||||
|
||||
interface GenerateFilterQueryParams {
|
||||
fieldKey: string;
|
||||
fieldValue: string;
|
||||
type: Values;
|
||||
}
|
||||
export const generateFilterQuery = ({
|
||||
fieldKey,
|
||||
fieldValue,
|
||||
type,
|
||||
}: GenerateFilterQueryParams): string => {
|
||||
let generatedQueryString = `${fieldKey} ${type} `;
|
||||
if (typeof fieldValue === 'number') {
|
||||
generatedQueryString += `(${fieldValue})`;
|
||||
} else {
|
||||
generatedQueryString += `('${fieldValue}')`;
|
||||
}
|
||||
|
||||
return generatedQueryString;
|
||||
};
|
||||
@@ -217,8 +217,6 @@ function ListPanelRenderer({
|
||||
log={logDetail.activeLog}
|
||||
selectedTab={logDetail.selectedTab}
|
||||
onClose={logDetail.onClose}
|
||||
onAddToQuery={logDetail.onAddToQuery}
|
||||
onClickActionItem={logDetail.onAddToQuery}
|
||||
onNavigateLog={logDetail.onNavigateLog}
|
||||
logs={logDetail.logs}
|
||||
isListViewPanel
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface ListLogDetail {
|
||||
selectedTab: VIEWS;
|
||||
logs: ILog[];
|
||||
onClose: () => void;
|
||||
onAddToQuery: ReturnType<typeof useLogDetailHandlers>['onAddToQuery'];
|
||||
onNavigateLog: (log: ILog) => void;
|
||||
}
|
||||
|
||||
@@ -49,13 +48,8 @@ export function useListRowInteraction({
|
||||
const isLogs = signal === TelemetrytypesSignalDTO.logs;
|
||||
const isTraces = signal === TelemetrytypesSignalDTO.traces;
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
|
||||
useLogDetailHandlers();
|
||||
|
||||
const logs = useMemo(
|
||||
() => (isLogs ? rows.map(rawRowToILog) : []),
|
||||
@@ -90,7 +84,6 @@ export function useListRowInteraction({
|
||||
selectedTab: selectedTab ?? VIEW_TYPES.OVERVIEW,
|
||||
logs,
|
||||
onClose: handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
onNavigateLog: handleSetActiveLog,
|
||||
}
|
||||
: null,
|
||||
@@ -100,7 +93,6 @@ export function useListRowInteraction({
|
||||
selectedTab,
|
||||
logs,
|
||||
handleCloseLogDetail,
|
||||
onAddToQuery,
|
||||
handleSetActiveLog,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -193,6 +193,40 @@ describe('ValueSelector', () => {
|
||||
|
||||
expect(onSearch).toHaveBeenLastCalledWith('pay');
|
||||
});
|
||||
|
||||
it('commits ALL even when the selection holds a value outside the list', async () => {
|
||||
// A search pick, or one an older time window offered: ALL must still go out as
|
||||
// `__all__`, not as a list that filters the panels to the truncated options.
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
render(
|
||||
<ValueSelector
|
||||
options={OPTIONS}
|
||||
variableType="dynamic"
|
||||
multiSelect
|
||||
showAllOption
|
||||
selection={{ value: ['searched'], allSelected: false }}
|
||||
onChange={onChange}
|
||||
emptyFallback={{ value: null, allSelected: true }}
|
||||
testId="variable-select-env"
|
||||
dynamic={{
|
||||
values: OPTIONS,
|
||||
relatedValues: [],
|
||||
complete: false,
|
||||
onSearch: jest.fn(),
|
||||
onSearchReset: jest.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openDropdown();
|
||||
await user.click(screen.getByText('ALL'));
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ allSelected: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearing', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ function commit(
|
||||
values,
|
||||
options: OPTIONS,
|
||||
showAllOption: true,
|
||||
isDynamic: false,
|
||||
emptyFallback: FALLBACK,
|
||||
...overrides,
|
||||
});
|
||||
@@ -60,6 +61,20 @@ describe('selectionFromCommittedValues', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A dynamic ALL is sent as `__all__`, so reading it as a list would filter the
|
||||
// panels down to the truncated option set.
|
||||
it('reads every option PLUS a value outside the list as ALL for a dynamic variable', () => {
|
||||
expect(
|
||||
commit([...OPTIONS, 'searched'], { isDynamic: true }).allSelected,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not read a dynamic subset PLUS a value outside the list as ALL', () => {
|
||||
expect(
|
||||
commit(['checkout', 'searched'], { isDynamic: true }).allSelected,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Derived from the values + options at commit time, never from the old selection.
|
||||
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
|
||||
expect(
|
||||
|
||||
@@ -102,6 +102,7 @@ function ValueSelector({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
isDynamic: !!dynamic,
|
||||
emptyFallback,
|
||||
});
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ interface CommittedValues {
|
||||
values: string[];
|
||||
options: string[];
|
||||
showAllOption: boolean;
|
||||
isDynamic: boolean;
|
||||
emptyFallback: VariableSelection;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export function selectionFromCommittedValues({
|
||||
values,
|
||||
options,
|
||||
showAllOption,
|
||||
isDynamic,
|
||||
emptyFallback,
|
||||
}: CommittedValues): VariableSelection {
|
||||
if (values.length === 0) {
|
||||
@@ -71,10 +73,12 @@ export function selectionFromCommittedValues({
|
||||
const customValues = values.filter((value) => !options.includes(value));
|
||||
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
|
||||
// — the next refetch would expand it back and drop what the user typed.
|
||||
// A dynamic ALL travels as `__all__` and is never re-materialized, so a value outside
|
||||
// its truncated list (a search pick, an older window's pick) loses nothing.
|
||||
const allSelected =
|
||||
showAllOption &&
|
||||
options.length > 0 &&
|
||||
customValues.length === 0 &&
|
||||
(isDynamic || customValues.length === 0) &&
|
||||
options.every((option) => values.includes(option));
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: TooltipProps['title'];
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,12 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,8 +42,11 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -91,6 +104,24 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,10 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +69,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color={color}
|
||||
testId={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
12
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: BadgeColorType }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'success' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'warning' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'highlight-danger' },
|
||||
};
|
||||
@@ -8,16 +8,12 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,28 +9,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -7,6 +8,11 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -42,33 +49,60 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow
|
||||
label="status"
|
||||
value={hasError ? 'error' : 'ok'}
|
||||
testId="span-hover-card-status"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
testId="span-hover-card-start"
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
testId="span-hover-card-duration"
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TraceTooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TraceTooltipRow
|
||||
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>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -130,6 +164,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -186,6 +186,8 @@ function TraceDetailsHeader({
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -176,9 +177,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -201,6 +212,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI tokens and cost when the summary has them', () => {
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits AI tokens and cost when the summary has no ai field', () => {
|
||||
mockSummary({ ...traceMetadata, ai: undefined });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI tokens when input and output are both zero', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
|
||||
},
|
||||
} as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero for a missing input', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: undefined },
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero AI tokens when input and output are both missing', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: {
|
||||
...traceMetadata.ai.tokens,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the cost when the summary sends null', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: { ...traceMetadata.ai, totalCost: null },
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
mockSummary(traceMetadata);
|
||||
|
||||
@@ -30,13 +30,9 @@
|
||||
// lives here. Shares visual treatment with the waterfall hover popover
|
||||
// but is positioned `fixed` instead of anchored.
|
||||
.tooltip {
|
||||
composes: tooltipSurface from '../shared.module.scss';
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
|
||||
import { DEFAULT_ROW_HEIGHT } from './constants';
|
||||
import { useCanvasSetup } from './hooks/useCanvasSetup';
|
||||
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
|
||||
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
handleHoverMouseLeave,
|
||||
]);
|
||||
|
||||
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
|
||||
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
}}
|
||||
>
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{tooltipContent.event ? (
|
||||
<EventTooltipContent
|
||||
eventName={tooltipContent.event.name}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
const CURSOR_OFFSET = 15;
|
||||
|
||||
interface CursorAnchor {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
function resolveAxis(cursor: number, size: number, viewport: number): number {
|
||||
const after = cursor + CURSOR_OFFSET;
|
||||
if (after + size <= viewport) {
|
||||
return after;
|
||||
}
|
||||
return Math.max(0, cursor - CURSOR_OFFSET - size);
|
||||
}
|
||||
|
||||
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
|
||||
export function useCursorTooltipPosition<T extends HTMLElement>(
|
||||
anchor: CursorAnchor | null,
|
||||
): RefObject<T> {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || !anchor) {
|
||||
return;
|
||||
}
|
||||
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
|
||||
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
|
||||
el.style.left = `${left}px`;
|
||||
el.style.top = `${top}px`;
|
||||
}, [anchor]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
}
|
||||
|
||||
.section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: space-between;
|
||||
column-gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nestedRow {
|
||||
padding-left: var(--spacing-6);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipRowProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
isNested?: boolean;
|
||||
isHeading?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TraceTooltipRow({
|
||||
label,
|
||||
value,
|
||||
isNested,
|
||||
isHeading,
|
||||
testId,
|
||||
}: TraceTooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, isNested && styles.nestedRow)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isHeading ? undefined : 'muted'}
|
||||
className={styles.rowLabel}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
{value !== undefined && (
|
||||
<Typography.Text size="small" className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceTooltipRow;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceTooltipSection({
|
||||
children,
|
||||
}: TraceTooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TraceTooltipSection;
|
||||
@@ -203,8 +203,9 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -394,6 +395,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--spacing-2);
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
@@ -28,7 +27,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -39,6 +44,8 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
|
||||
import { isAiSpan } from '../../../utils/aiTraceUtils';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
@@ -318,14 +325,23 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{isAiSpan(span) ? (
|
||||
<Sparkle
|
||||
size={10}
|
||||
color={effectiveColor}
|
||||
fill={effectiveColor}
|
||||
className={styles.aiSpanIcon}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className={styles.treeLabel}>
|
||||
@@ -944,9 +960,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
<SpanHintBadge span={span} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
20
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
20
frontend/src/pages/TraceDetailsV3/shared.module.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
.tooltipBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.tooltipTitle {
|
||||
font-family: 'SF Mono', 'Geist Mono', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tooltipSurface {
|
||||
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-4);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -103,6 +105,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
|
||||
|
||||
export const traceDetailsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
scenario: choiceControl<TraceScenario>('Scenario', {
|
||||
group: TRACE,
|
||||
description:
|
||||
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
|
||||
options: TRACE_SCENARIOS,
|
||||
value: 'checkout',
|
||||
}),
|
||||
spans: countControl('Spans', {
|
||||
group: TRACE,
|
||||
description:
|
||||
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const trace = {
|
||||
scenario: values.scenario,
|
||||
spans: values.spans,
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
@@ -288,7 +298,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
effect: (values) => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
startDownload,
|
||||
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
|
||||
args: { bottomStrip: true, dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* One span per HTTP status badge the waterfall draws: informational, success,
|
||||
* redirect, client and server errors, and a span with no status code and so no
|
||||
* badge.
|
||||
*/
|
||||
export const StatusCodes: Story = {
|
||||
args: {
|
||||
scenario: 'status-codes',
|
||||
spans: scenarioSpanCount('status-codes'),
|
||||
spanPanel: false,
|
||||
},
|
||||
};
|
||||
|
||||
const AI_SPANS = scenarioSpanCount('ai');
|
||||
|
||||
/**
|
||||
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
|
||||
* token and cost totals in the header, the span panel on an LLM call with its
|
||||
* tokens and cost, and that span's hover card open on its usage breakdown.
|
||||
*/
|
||||
export const AiTrace: Story = {
|
||||
args: { scenario: 'ai', spans: AI_SPANS },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const row = await within(canvasElement).findByTestId(
|
||||
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await fireEvent.mouseOver(row);
|
||||
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on Highlight errors, which writes `has_error = true` into the filter and
|
||||
* expands the bar to show it.
|
||||
|
||||
@@ -23,6 +23,10 @@ interface SpanTemplate {
|
||||
name: string;
|
||||
kind: string;
|
||||
failing?: boolean;
|
||||
/** The span the panel opens on when nothing fails. */
|
||||
focus?: boolean;
|
||||
/** `response_status_code`; failing spans default to 503, the rest to 200. */
|
||||
statusCode?: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** One span per badge colour, plus a span with no status code and so no badge. */
|
||||
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
|
||||
['200', 'GET /status/ok'],
|
||||
['101', 'GET /ws/upgrade'],
|
||||
['201', 'POST /status/created'],
|
||||
['204', 'DELETE /status/no-content'],
|
||||
['301', 'GET /status/moved'],
|
||||
['304', 'GET /status/not-modified'],
|
||||
['400', 'POST /status/bad-request'],
|
||||
['401', 'GET /status/unauthorized'],
|
||||
['404', 'GET /status/not-found'],
|
||||
['429', 'GET /status/rate-limited'],
|
||||
['500', 'GET /status/internal-error'],
|
||||
['502', 'GET /status/bad-gateway'],
|
||||
['503', 'GET /status/unavailable'],
|
||||
['504', 'GET /status/timeout'],
|
||||
['', 'internal.compute'],
|
||||
].map(
|
||||
([statusCode, name]): SpanTemplate => ({
|
||||
service: 'status-api',
|
||||
name,
|
||||
kind: statusCode ? 'Server' : 'Internal',
|
||||
failing: Number(statusCode) >= 500,
|
||||
statusCode,
|
||||
attributes: statusCode
|
||||
? {
|
||||
'http.method': name.split(' ')[0],
|
||||
'http.status_code': Number(statusCode),
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
|
||||
* cover cache counted inside input (OpenAI), cache counted beside it
|
||||
* (Anthropic), reasoning, and an embedding with input only.
|
||||
*/
|
||||
const AI_TEMPLATES: SpanTemplate[] = [
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent support-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'support-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat gpt-4o',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'gpt-4o-2024-08-06',
|
||||
'gen_ai.usage.input_tokens': 1840,
|
||||
'gen_ai.usage.output_tokens': 212,
|
||||
'gen_ai.usage.cache_read.input_tokens': 1024,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 0,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00566,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool search_docs',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.tool.name': 'search_docs' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat claude-sonnet-4',
|
||||
kind: 'Client',
|
||||
focus: true,
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'claude-sonnet-4-20250514',
|
||||
'gen_ai.usage.input_tokens': 86,
|
||||
'gen_ai.usage.output_tokens': 640,
|
||||
'gen_ai.usage.cache_read.input_tokens': 4200,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 1300,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.01853,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'docs-api',
|
||||
name: 'GET /docs/search',
|
||||
kind: 'Server',
|
||||
attributes: {
|
||||
'http.method': 'GET',
|
||||
'http.route': '/docs/search',
|
||||
'http.status_code': 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool fetch_ticket',
|
||||
kind: 'Internal',
|
||||
failing: true,
|
||||
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent triage-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'triage-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'embeddings text-embedding-3-small',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'text-embedding-3-small',
|
||||
'gen_ai.usage.input_tokens': 312,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat o3-mini',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'o3-mini-2025-01-31',
|
||||
'gen_ai.usage.input_tokens': 920,
|
||||
'gen_ai.usage.output_tokens': 1480,
|
||||
'gen_ai.usage.reasoning.output_tokens': 1152,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.007524,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
|
||||
|
||||
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
|
||||
|
||||
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
|
||||
checkout: SPAN_TEMPLATES,
|
||||
'status-codes': STATUS_CODE_TEMPLATES,
|
||||
ai: AI_TEMPLATES,
|
||||
};
|
||||
|
||||
/** Spans it takes for the scenario to show every template once. */
|
||||
export const scenarioSpanCount = (scenario: TraceScenario): number =>
|
||||
SCENARIO_TEMPLATES[scenario].length;
|
||||
|
||||
const TRACE_SERVICES = [
|
||||
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
|
||||
];
|
||||
@@ -146,6 +289,7 @@ interface BuiltSpan {
|
||||
}
|
||||
|
||||
interface TraceOptions {
|
||||
scenario: TraceScenario;
|
||||
spans: number;
|
||||
errors: boolean;
|
||||
/** Epoch milliseconds the root span starts at. */
|
||||
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
|
||||
`b17e${index.toString(16).padStart(12, '0')}`;
|
||||
|
||||
/**
|
||||
* The span the story opens the panel on: the first failing one, so its events
|
||||
* and error styling are on screen, and the root when nothing fails.
|
||||
* The span the story opens the panel on: the scenario's focus span, else the
|
||||
* first failing one, so its events and error styling are on screen, and the
|
||||
* root when neither is in the trace.
|
||||
*/
|
||||
export const focusSpanIndex = (spans: number): number => {
|
||||
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
|
||||
export const focusSpanIndex = (
|
||||
scenario: TraceScenario,
|
||||
spans: number,
|
||||
): number => {
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
const focus = templates.findIndex((template) => template.focus);
|
||||
const failing = templates.findIndex((template) => template.failing);
|
||||
const index = focus >= 0 ? focus : failing;
|
||||
|
||||
return failing >= 0 && failing < spans ? failing : 0;
|
||||
return index >= 0 && index < spans ? index : 0;
|
||||
};
|
||||
|
||||
const buildSpans = ({
|
||||
scenario,
|
||||
spans,
|
||||
errors,
|
||||
traceStart,
|
||||
}: TraceOptions): BuiltSpan[] => {
|
||||
const built: BuiltSpan[] = [];
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
|
||||
for (let index = 0; index < spans; index += 1) {
|
||||
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
|
||||
const template = templates[index % templates.length];
|
||||
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
|
||||
// Children fit inside their parent's window, second child after the first,
|
||||
// so no span outlives its parent and no two siblings overlap.
|
||||
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
|
||||
status_code: span.hasError ? 2 : 1,
|
||||
status_code_string: span.hasError ? 'Error' : 'Ok',
|
||||
status_message: span.hasError ? 'upstream unavailable' : '',
|
||||
response_status_code: span.hasError ? '503' : '200',
|
||||
response_status_code:
|
||||
span.template.statusCode ?? (span.hasError ? '503' : '200'),
|
||||
has_children: span.hasChildren,
|
||||
sub_tree_node_count: span.subTreeNodeCount,
|
||||
level: span.level,
|
||||
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
const numberAttribute = (span: BuiltSpan, key: string): number =>
|
||||
Number(span.template.attributes[key] ?? 0);
|
||||
|
||||
/** The trace's AI totals, summed over its spans the way the backend does. */
|
||||
const traceAiSummary = (
|
||||
spans: BuiltSpan[],
|
||||
): GetTraceSummary200['data']['ai'] => {
|
||||
const aiSpans = spans.filter(({ template }) =>
|
||||
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
|
||||
);
|
||||
|
||||
if (aiSpans.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sum = (key: string): number =>
|
||||
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
input: sum('gen_ai.usage.input_tokens'),
|
||||
output: sum('gen_ai.usage.output_tokens'),
|
||||
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
|
||||
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
|
||||
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
|
||||
},
|
||||
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
|
||||
};
|
||||
};
|
||||
|
||||
export const traceSummaryResponse = (
|
||||
options: TraceOptions & { missingSpans: boolean },
|
||||
): GetTraceSummary200 => {
|
||||
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
|
||||
totalSpansCount: spans.length,
|
||||
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
|
||||
hasMissingSpans: options.missingSpans,
|
||||
ai: traceAiSummary(spans),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -472,7 +657,7 @@ export const spanLogRows = (
|
||||
): RawRow[] => {
|
||||
const step = ROOT_DURATION_MS / Math.max(count, 1);
|
||||
const spans = buildSpans(options);
|
||||
const focus = spans[focusSpanIndex(options.spans)];
|
||||
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
RESERVED_ERROR,
|
||||
} from './utils/generateColorPair';
|
||||
|
||||
/** Like `||`, but keeps `0`. */
|
||||
const valueOrUndefined = <T>(value: T): T | undefined =>
|
||||
value || value === 0 ? value : undefined;
|
||||
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
* Falsy values count as missing, except `0`.
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
@@ -22,11 +27,14 @@ export function getSpanAttribute(
|
||||
span: {
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
tagMap?: Record<string, any>;
|
||||
},
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return (
|
||||
span.resource?.[key] || span.attributes?.[key] || (span as any).tagMap?.[key]
|
||||
valueOrUndefined(span.resource?.[key]) ??
|
||||
valueOrUndefined(span.attributes?.[key]) ??
|
||||
valueOrUndefined(span.tagMap?.[key])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
AiSpanKind,
|
||||
getAiSpanKind,
|
||||
getTotalInputTokens,
|
||||
isAiSpan,
|
||||
} from '../aiTraceUtils';
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getAiSpanKind', () => {
|
||||
it.each<[Record<string, string | number>, AiSpanKind]>([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
|
||||
[
|
||||
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
|
||||
AiSpanKind.Tool,
|
||||
],
|
||||
[
|
||||
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
|
||||
AiSpanKind.Agent,
|
||||
],
|
||||
])('classifies %p as %s', (attributes, kind) => {
|
||||
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
|
||||
});
|
||||
|
||||
it('returns undefined for non-AI spans and empty values', () => {
|
||||
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
|
||||
expect(isAiSpan(spanWith({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalInputTokens', () => {
|
||||
it.each([
|
||||
// Cache reported inside input (OpenAI): input already includes it.
|
||||
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
|
||||
// Cache reported separately (Anthropic): add it.
|
||||
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
|
||||
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
|
||||
[{ input: 10 }, 10],
|
||||
[{ cacheRead: 600 }, 600],
|
||||
])('%p → %p', (tokens, expected) => {
|
||||
expect(getTotalInputTokens(tokens)).toBe(expected);
|
||||
});
|
||||
});
|
||||
168
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
168
frontend/src/pages/TraceDetailsV3/utils/aiTraceUtils.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
requestModel: 'gen_ai.request.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
return getSpanAttribute(span, key) !== undefined;
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
|
||||
/** Token counts; `null` or missing means the backend did not send the value. */
|
||||
export interface AiTokenCounts {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export function hasValue(value: number | null | undefined): value is number {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input including cache read/write, counted once. Providers that report cache
|
||||
* inside `input` (OpenAI) have `input >= cache`; those that report it
|
||||
* separately (Anthropic) usually have `input < cache`.
|
||||
*/
|
||||
export function getTotalInputTokens({
|
||||
input,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
}: AiTokenCounts): number {
|
||||
const inputValue = input ?? 0;
|
||||
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
|
||||
return inputValue < cache ? inputValue + cache : inputValue;
|
||||
}
|
||||
|
||||
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
|
||||
export function getTokenLabel(
|
||||
prefix: string,
|
||||
count: number | null | undefined,
|
||||
): string {
|
||||
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
|
||||
}
|
||||
|
||||
/** Missing values render as `0`. */
|
||||
export function formatTokens(value: number | null | undefined): string {
|
||||
return hasValue(value) ? value.toLocaleString('en-US') : '0';
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsString(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = getSpanAttribute(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getSpanAttribute(span, key);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getGenAiAttributeAsNumber(
|
||||
span,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
),
|
||||
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
|
||||
};
|
||||
const hasUsage = Object.values(usage).some((value) => value !== undefined);
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
|
||||
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
|
||||
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
|
||||
return {
|
||||
input: usage.inputTokens,
|
||||
output: usage.outputTokens,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: usage.reasoningTokens,
|
||||
};
|
||||
}
|
||||
@@ -56,12 +56,18 @@ test.describe('Trace details — flamegraph', () => {
|
||||
}) => {
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
|
||||
|
||||
// "status: error" only renders in the hover card (not in waterfall rows),
|
||||
// The status row only renders in the hover card (not in waterfall rows),
|
||||
// so it proves both that the card opened and that we hovered the right
|
||||
// (error) span — the bar was targeted by id via the span→rect map.
|
||||
await expect(page.getByText('status: error')).toBeVisible();
|
||||
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
|
||||
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
|
||||
await expect(page.getByTestId('span-hover-card-status')).toHaveText(
|
||||
/status\s*error/,
|
||||
);
|
||||
await expect(page.getByTestId('span-hover-card-start')).toHaveText(
|
||||
/start\s*[\d.]+ ms/,
|
||||
);
|
||||
await expect(page.getByTestId('span-hover-card-duration')).toHaveText(
|
||||
/duration\s*[\d.]+/,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({
|
||||
|
||||
Reference in New Issue
Block a user