Compare commits

...

6 Commits

Author SHA1 Message Date
Gaurav Tewari
0b941fe606 chore: add comment back 2026-10-21 16:18:52 +05:30
Gaurav Tewari
f45d683ec2 feat(trace-details): trace details & thread tab 2026-10-19 11:56:16 +05:30
Gaurav Tewari
edca5d2e3e refactor(trace-details): minor refactor 2026-10-19 11:56:16 +05:30
Gaurav Tewari
6c1f12f5d0 feat(trace-details): waterfall tooltip redesign and AI span badges (#13066)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Redesigned span tooltip with AI usage breakdown; tooltip rows/styles
shared under `TraceTooltip`.
- AI badge and icons on gen-ai spans in the waterfall.
- Header shows trace-level AI tokens (with breakdown tooltip) and cost
from the summary's `ai` field.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Close -
https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=263424271&issue=SigNoz%7Cnerve-pod%7C397

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/b22bcbfb-8f74-40ac-a263-d5ac2a1f23b0


TDD-
https://app.notion.com/p/signoz/Trace-Details-Changes-for-AI-o11y-3ebfcc6bcd1980b185b4c17cd1810075?source=copy_link



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
figma -
https://www.figma.com/design/UPcVP7G1Scd5PLOQzvI9Q1/Trace-Explorer?node-id=4874-3499&t=5QH0Uv1oiS7v1Rfe-0

- We changed tooltip designed a little bit, we added same font of
subheading ( usage breakdown ) as of heading (span) , min and max
heights also cost icon could be better .
- We have introduced a new logic to calculate total 
```
if input < cache_read + cache_write:
  total_input = input + cache_read + cache_write
else
  total_input = input
  
```
- Also now if backend sends any of ai related field we will display all
field with value set as zero .
- We have updated events tooltip as well (styles ). 
- have added storybook as well . pages-traces-trace-details--ai-trace

-  Span detail changes will be done in  last pr :) 
Stack 2/4, based on #13065.

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-19 05:52:16 +00:00
Aditya Singh
6c6632f940 test(saved-views): authz stories for each missing permission (#13172)
Some checks failed
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
#### Description
- The saved views stories now have an `Authz` folder, with one story per
missing permission. The stories follow the dashboard and billing authz
stories.
- `NoList`, `NoCreate`, `NoRead`, `NoUpdate` and `NoDelete` each deny
one permission.
- `NoCreateSaveMenu`, `NoUpdateSaveMenu` and `NoReadOpenView` show the
same denials in the `✓` menu and on an open view.
  - `ReadOnly` denies create, update and delete together.
- `CheckLoading` and `CheckFailed` cover a pending check and a failed
check.
- Each story checks what is disabled and what still works.
- The main stories move to `Components/Saved Views/Overview`, and
`NoAccess` is now `NoList`.
- The header `✓` and `✕` buttons get a gap again. After the ui upgrade
they are link buttons without padding, so they touched.

#### Issues closed by this PR

#### Screenshot

Before

<img width="608" height="120" alt="image"
src="https://github.com/user-attachments/assets/e9fcf890-3355-4cd5-82e4-a4e0221aaacd"
/>


After

<img width="734" height="212" alt="image"
src="https://github.com/user-attachments/assets/7ce0d2d3-7155-4365-bbab-aad0fef6fc29"
/>


Part of https://github.com/SigNoz/events-pod/issues/71
2026-10-16 19:26:53 +00:00
Aditya Singh
39b752c428 feat(logs): move the list row filter chips off v3 attribute keys (#13148)
#### Description

- The "Add to query" chip on a logs list row no longer fetches v3
`attribute_keys` before it adds the filter. The hook builds the filter
item with the same util the log details drawer uses, then redirects. The
stored key has an empty data type and context. The filter expression is
built from key and value, so the request is unchanged.
- `LogDetail` dropped two props it never read, `onAddToQuery` and
`onClickActionItem`, from its type and from six call sites.
- Removed the code that only existed for those props: the `EntityLogs`
callback, `generateFilterQuery`, `getOldLogsOperatorFromNew` and
`ActionItemProps`. Removed `onGroupByAttribute` from `useActiveLog`,
which nothing called.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/160


#### Screenshot



https://github.com/user-attachments/assets/7cce500a-6dbf-45cb-937f-7366d515b0b9



https://github.com/user-attachments/assets/f5cbd1bd-0863-4633-ad6e-2fd15f7ae7c5



https://github.com/user-attachments/assets/2fb7dbeb-c474-4730-9a9d-363257a7fff7



#### Additional Information

- Only one user path runs the changed hook: the field chips on a log row
in the logs explorer list and in live logs.
- The merged where clause shows adjacent clauses without `AND`. The
grammar allows an implicit AND and the query runs. This is pre-existing
in `convertFiltersToExpressionWithExistingQuery` and is not changed
here.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-10-16 18:32:25 +00:00
70 changed files with 2441 additions and 915 deletions

View File

@@ -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']>;

View File

@@ -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}
/>,

View File

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

View File

@@ -259,8 +259,6 @@ function LiveLogsList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}

View File

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

View File

@@ -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 => {

View File

@@ -381,8 +381,6 @@ function LogsExplorerList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}

View File

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

View File

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

View File

@@ -78,7 +78,7 @@
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
gap: var(--spacing-4);
}
.collapseIcon {

View File

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

View File

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

View File

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

View 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');
});
});

View File

@@ -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());

View File

@@ -25,5 +25,4 @@ export type UseActiveLog = {
operator: string,
dataType?: DataTypes,
) => void;
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
};

View File

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

View File

@@ -0,0 +1,58 @@
import { MouseEvent, ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useCopySpanLink } from '../useCopySpanLink';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn() },
}));
// The router only knows the stale URL; nuqs has since written ?tab=thread.
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
{children}
</MemoryRouter>
);
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent<HTMLElement>;
describe('useCopySpanLink', () => {
beforeEach(() => {
mockCopy.mockClear();
__setSearchParamsGetterForTest(
() => new URLSearchParams('tab=thread&spanId=old'),
);
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('builds the link from the live URL, keeping its tab', () => {
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
wrapper,
});
result.current.onSpanCopy(clickEvent);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe('/trace/abc');
expect(link.searchParams.get('tab')).toBe('thread');
expect(link.searchParams.get('spanId')).toBe('new');
});
});

View File

@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { getAbsoluteUrl } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
@@ -15,7 +15,6 @@ interface SpanLike {
export const useCopySpanLink = (
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
@@ -28,21 +27,22 @@ export const useCopySpanLink = (
event.preventDefault();
event.stopPropagation();
urlQuery.delete('spanId');
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
params.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, urlQuery, pathname, setCopy],
[span, pathname, setCopy],
);
return {

View File

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

View File

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

View File

@@ -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,
],
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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' },
};

View File

@@ -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 {

View File

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

View File

@@ -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),
},
};
}, [

View File

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

View File

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

View File

@@ -29,6 +29,10 @@
}
}
.tabs {
flex-shrink: 0;
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
@@ -13,16 +13,20 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -60,6 +64,14 @@ function TraceDetailsHeader({
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -93,13 +105,14 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
<div className={styles.wrapper}>
<div className={styles.header}>
@@ -120,9 +133,10 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -138,6 +152,7 @@ function TraceDetailsHeader({
icon
color="secondary"
aria-label="Analytics"
testId="trace-analytics-button"
onClick={handleToggleAnalytics}
>
<ChartPie size={14} />
@@ -172,28 +187,32 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceSummary ? (
{!traceSummary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -0,0 +1,43 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
};
return (
<div className={styles.tabs}>
<ToggleGroup
variant="outlined"
color="secondary"
type="single"
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
/>
</div>
);
}
export default TraceDetailsTabs;

View File

@@ -50,6 +50,7 @@ function TraceOptionsMenu({
type: 'item',
value: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
testId: 'toggle-trace-details',
onClick: onToggleTraceDetails,
},
{
@@ -127,6 +128,7 @@ function TraceOptionsMenu({
icon
color="secondary"
aria-label="Trace options"
testId="trace-options-button"
>
<Settings2 size={14} />
</Button>

View File

@@ -1,6 +1,8 @@
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 { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -176,9 +178,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 +213,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);
@@ -233,3 +316,88 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – tabs', () => {
beforeEach(() => {
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
rootServiceEntryPoint: 'Missing Span',
hasMissingSpans: true,
},
isLoading: false,
});
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(screen.getByTestId('trace-analytics-button')).toBeInTheDocument();
expect(screen.getByTestId('trace-options-button')).toBeInTheDocument();
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
});
it('keeps only the metadata row on the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
expect(
screen.queryByTestId('trace-analytics-button'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('trace-options-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-options-button'));
await user.click(await screen.findByTestId('toggle-trace-details'));
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
await user.click(screen.getByTestId('trace-analytics-button'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'true',
);
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
'data-open',
'false',
);
});
});

View File

@@ -1,10 +1,3 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.layoutRow {
display: flex;
flex: 1;

View File

@@ -0,0 +1,505 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import {
SPAN_DETAILS_PANEL_MIN_WIDTH,
SpanDetailVariant,
} from '../SpanDetailsPanel/constants';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from '../TraceWaterfall/types';
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from '../utils';
import cx from 'classnames';
import styles from './TraceDetailsOverview.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
interface TraceDetailsOverviewProps {
filteredSpanIds: string[];
isFilterActive: boolean;
onHasTraceDataChange: (hasTraceData: boolean) => void;
dataLoadedFiredForRef: MutableRefObject<string>;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
onHasTraceDataChange,
dataLoadedFiredForRef,
}: TraceDetailsOverviewProps): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
safeNavigate({ search: params.toString() }, { replace: true });
}, [safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
dataLoadedFiredForRef,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const hasTraceData = !showNoData && allSpans.length > 0;
useEffect(() => {
onHasTraceDataChange(hasTraceData);
return (): void => onHasTraceDataChange(false);
}, [hasTraceData, onHasTraceDataChange]);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

View File

@@ -0,0 +1,5 @@
function TraceDetailsThread(): JSX.Element {
return <div data-testid="trace-details-thread" />;
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,6 @@
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,21 @@
import { ReactNode } from 'react';
import TraceStoreSync from '../stores/TraceStoreSync';
import styles from './TraceDetailsWrapper.module.scss';
interface TraceDetailsWrapperProps {
children: ReactNode;
}
function TraceDetailsWrapper({
children,
}: TraceDetailsWrapperProps): JSX.Element {
return (
<TraceStoreSync>
<div className={styles.root}>{children}</div>
</TraceStoreSync>
);
}
export default TraceDetailsWrapper;

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { SpanV3 } from 'types/api/trace/getTraceV3';
@@ -30,7 +31,6 @@ function TraceFlamegraph({
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
const urlQuery = useUrlQuery();
const history = useHistory();
const { search } = useLocation();
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
urlQuery.get('spanId') || '',
);
@@ -42,14 +42,14 @@ function TraceFlamegraph({
const handleSpanClick = useCallback(
(spanId: string): void => {
setFirstSpanAtFetchLevel(spanId);
const searchParams = new URLSearchParams(search);
const searchParams = getUnstableCurrentSearchParams();
//tood: use from query params constants
if (searchParams.get('spanId') !== spanId) {
searchParams.set('spanId', spanId);
history.replace({ search: searchParams.toString() });
}
},
[history, search],
[history],
);
const previewFields = useTraceStore((s) => s.previewFields);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
@@ -12,6 +12,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -163,12 +164,11 @@ function Filters({
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
const searchParams = getUnstableCurrentSearchParams();
if (spanId) {
searchParams.set('spanId', spanId);
} else {
@@ -177,7 +177,7 @@ function Filters({
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
[filteredSpanIds, history],
);
const isFilterActive = committedExpression.trim().length > 0;

View File

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

View File

@@ -20,15 +20,20 @@ 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';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
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}>
@@ -660,19 +676,19 @@ function Success(props: ISuccessProps): JSX.Element {
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: SpanV3): void => {
setSelectedSpan(span);
const params = getUnstableCurrentSearchParams();
if (span?.span_id) {
urlQuery.set('spanId', span?.span_id);
params.set('spanId', span?.span_id);
}
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: params.toString() }, { replace: true });
},
[setSelectedSpan, urlQuery, safeNavigate],
[setSelectedSpan, safeNavigate],
);
// Left side columns using TanStack React Table (extensible for future columns)
@@ -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>
);
})}

View File

@@ -1,6 +1,10 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
// Local identity-proxy mock for this module so `styles.foo` resolves to
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
@@ -226,6 +230,11 @@ describe('Span Click User Flows', () => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('clicking span updates URL with spanId parameter', async () => {
@@ -260,7 +269,6 @@ describe('Span Click User Flows', () => {
) as HTMLElement;
await user.click(spanElement);
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringContaining('spanId=span-1'),
@@ -428,10 +436,6 @@ describe('Span Click User Flows', () => {
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringMatching(
@@ -441,4 +445,41 @@ describe('Span Click User Flows', () => {
{ replace: true },
);
});
it('keeps a tab param the router has not seen when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// nuqs wrote ?tab=thread to the real URL; react-router's copy is stale.
__setSearchParamsGetterForTest(() => new URLSearchParams('tab=thread'));
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
isFullDataLoaded={false}
localUncollapsedNodes={new Set()}
setLocalUncollapsedNodes={jest.fn()}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
filteredSpanIds={[]}
isFilterActive={false}
/>,
undefined,
{ initialRoute: '/trace' },
);
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
expect(mockSafeNavigate).toHaveBeenCalledWith(
{ search: expect.stringMatching(/tab=thread.*spanId=span-1/) },
{ replace: true },
);
});
});

View File

@@ -53,3 +53,8 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
export enum TraceDetailsTab {
Overview = 'overview',
Thread = 'thread',
}

View File

@@ -0,0 +1,26 @@
import { useCallback, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
/** Span details position, persisted under the same key Overview uses. */
export function useSpanDetailVariant(): [
SpanDetailVariant,
(variant: SpanDetailVariant) => void,
] {
const [variant, setVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const changeVariant = useCallback((next: SpanDetailVariant): void => {
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
setVariant(next);
}, []);
return [variant, changeVariant];
}

View File

@@ -0,0 +1,15 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { TraceDetailsTab } from '../constants';
export function useTraceDetailsTab(): UseQueryStateReturn<
TraceDetailsTab,
TraceDetailsTab
> {
return useQueryState(
'tab',
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
TraceDetailsTab.Overview,
),
);
}

View File

@@ -1,90 +1,21 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useCallback, useEffect, useRef, useState } from 'react';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import NoData from './NoData/NoData';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import {
SPAN_DETAILS_PANEL_MIN_WIDTH,
SpanDetailVariant,
} from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import { TraceDetailsTab } from './constants';
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
() => ({
spanId: urlQuery.get('spanId') || '',
isUncollapsed: urlQuery.get('spanId') !== '',
}),
);
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
Set<string>
>(new Set());
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const selectedSpanId = urlQuery.get('spanId') || undefined;
const { safeNavigate } = useSafeNavigate();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
const [hasTraceData, setHasTraceData] = useState(false);
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const dataLoadedFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -94,421 +25,31 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
// The filter query lives in Filters' local state, which unmounts with the
// Overview tab; drop its result too so both start empty on return.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
if (!isOverview) {
handleFilteredSpansChange([], false);
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {
const parentIds = new Set(
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
);
setLocalUncollapsedNodes(parentIds);
}
}, [isFullDataLoaded, allSpans]);
// Backend mode: sync uncollapsed state from API response
useEffect(() => {
if (
!isFullDataLoaded &&
traceData &&
traceData.payload &&
traceData.payload.uncollapsedSpans
) {
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
}
}, [traceData, isFullDataLoaded]);
// Tracks whether we've already done the initial URL→selectedSpan handoff
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
useEffect(() => {
if (deepLinkResolvedRef.current) {
return;
}
if (allSpans.length === 0) {
return;
}
if (selectedSpanId) {
const span = allSpans.find((s) => s.span_id === selectedSpanId);
if (!span) {
// Span not in the current window — wait for more data (backend
// pagination) before marking resolved.
return;
}
setSelectedSpan(span);
} else {
setSelectedSpan((prev) => prev ?? allSpans[0]);
}
deepLinkResolvedRef.current = true;
}, [selectedSpanId, allSpans]);
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
// visible. Keyed on URL `spanId`(selectedSpanId).
useEffect(() => {
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
return;
}
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
if (ancestors.size === 0) {
return;
}
setLocalUncollapsedNodes((prev) => {
// Check if all ancestors are already expanded — avoid unnecessary state update
let allPresent = true;
for (const id of ancestors) {
if (!prev.has(id)) {
allPresent = false;
break;
}
}
if (allPresent) {
return prev;
}
const next = new Set(prev);
for (const id of ancestors) {
next.add(id);
}
return next;
});
}, [isFullDataLoaded, selectedSpanId, allSpans]);
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
const handleCollapseChange = (key: string): void => {
logTraceEvent(
key === 'flame'
? TraceDetailEvents.FlameGraphToggled
: TraceDetailEvents.WaterfallToggled,
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
);
setActiveKeys((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
);
};
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
() =>
(getLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
);
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const handleVariantChange = useCallback(
(newVariant: SpanDetailVariant): void => {
setLocalStorageKey(
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
newVariant,
);
setSpanDetailVariant(newVariant);
},
[],
);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
}, [isOverview, handleFilteredSpansChange]);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
<TraceDetailsWrapper>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={hasTraceData}
/>
{isOverview ? (
<TraceDetailsOverview
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onHasTraceDataChange={setHasTraceData}
dataLoadedFiredForRef={dataLoadedFiredForRef}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall')
? waterfallChildren
: null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</div>
</TraceStoreSync>
) : (
<TraceDetailsThread />
)}
</TraceDetailsWrapper>
);
}

View 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);
}

View File

@@ -4,36 +4,28 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
* Bridges React-managed inputs (`userPreferences` from AppContext and the
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
* is pushed by TraceDetailsOverview, which owns the span data.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
* paints with the default colour first and then swaps to the user's
* persisted choice. AppProvider fires the prefs query as soon as the user
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
availableColorByFields,
children,
}: TraceStoreSyncProps): JSX.Element | null {
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);
}, [userPreferences]);

View File

@@ -37,7 +37,7 @@ interface TraceStoreState {
availableColorByOptions: ColorByOption[];
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {

View File

@@ -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) => {

View File

@@ -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.

View File

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

View File

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

View File

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

View 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,
};
}

View File

@@ -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 ({