Compare commits

...

11 Commits

Author SHA1 Message Date
Gaurav Tewari
8eb33ef05b test(trace-details): add useTraceThread hook tests 2026-10-20 18:45:02 +05:30
Gaurav Tewari
2429549df9 Merge branch 'feat/trace-details-thread' into feat/trace-details-thread-tests 2026-10-20 18:44:32 +05:30
Gaurav Tewari
681e96df82 fix: ux issue (empty menu items + refetch on scroll) 2026-10-20 18:42:42 +05:30
Gaurav Tewari
ef36279e1c chore: minor ux improvements 2026-10-20 13:41:30 +05:30
Gaurav Tewari
fc62476e6b test(trace-details): add AI thread view tests and stories 2026-10-19 11:56:16 +05:30
Gaurav Tewari
c64af15ac6 feat(trace-details): add AI thread view 2026-10-19 11:56:16 +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
125 changed files with 6451 additions and 921 deletions

View File

@@ -142,7 +142,7 @@ const preview: Preview = {
'Logs',
['Explorer', 'Live Tail', 'Saved Views', 'Pipelines', 'Settings'],
'Traces',
['Explorer', 'Trace Details', 'Funnel Details'],
['Explorer', 'Trace Details', ['Overview', 'Thread'], 'Funnel Details'],
'Metrics',
['Explorer'],
'Infrastructure',

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

@@ -1,6 +1,7 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { formUrlParams } from 'utils/traceUtils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -20,6 +21,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
tab: TraceDetailsTab.Thread,
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
@@ -44,7 +45,10 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
to={{
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
search: `?tab=${TraceDetailsTab.Thread}`,
}}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>

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

@@ -0,0 +1,44 @@
import ReactMarkdown, { uriTransformer } from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Model output is untrusted: never add `rehype-raw`, so raw HTML renders as text.
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS: Components = {
a: ({ node: _node, children, ...props }): JSX.Element => (
<a {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
// Images become links so viewing a message never fetches a third-party URL.
img: ({ src, alt }): JSX.Element => (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || src}
</a>
),
};
interface AIMarkdownProps {
content: string;
className?: string;
}
function AIMarkdown({ content, className }: AIMarkdownProps): JSX.Element {
return (
<ReactMarkdown
className={className}
remarkPlugins={MD_PLUGINS}
// Images render as links, so their URIs need the same sanitising links get.
transformImageUri={uriTransformer}
components={MD_COMPONENTS}
>
{content}
</ReactMarkdown>
);
}
AIMarkdown.defaultProps = {
className: undefined,
};
export default AIMarkdown;

View File

@@ -0,0 +1,376 @@
// Design token Paragraph/Base/400.
@mixin paragraph-base {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
color: var(--l1-foreground);
}
// Typography sets size, weight and line height; the mixin adds the rest.
.roleLabel {
@include paragraph-base;
}
.parts {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.empty {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.textBox {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-5);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
min-width: 0;
}
.text {
@include paragraph-base;
color: var(--l2-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(:is(ol, ul)) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
:global(li::marker) {
color: var(--l2-foreground);
}
:global(:is(h1, h2, h3, h4, h5, h6)) {
margin: var(--spacing-6) 0 var(--spacing-4);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-20);
color: var(--l1-foreground);
}
:global(:is(h1, h2)) {
font-size: var(--periscope-font-size-medium);
}
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
:global(strong) {
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
:global(a) {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:global(code:not(pre code)) {
padding: 0 var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l3-background);
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--l1-foreground);
}
:global(pre) {
position: relative;
margin: 0 0 var(--spacing-4);
}
// The highlighter sets its box with inline styles; syntax colours stay its own.
:global(pre > div:not(.code-copy-btn)) {
margin: 0 !important;
padding: var(--spacing-4) var(--spacing-5) !important;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2) !important;
background: var(--l1-background) !important;
overflow-x: auto;
}
:global(pre code) {
font-family: var(--font-mono) !important;
}
:global(blockquote) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-5);
border-left: 2px solid var(--l3-border);
color: var(--l2-foreground);
}
:global(hr) {
margin: var(--spacing-6) 0;
border: none;
border-top: 1px solid var(--l2-border);
}
}
.showMore {
align-self: flex-start;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--primary);
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
max-width: 100%;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
color: var(--l2-foreground);
min-width: 0;
@include paragraph-base;
&.isPlain {
padding: 0;
background: none;
}
}
.dot {
flex-shrink: 0;
width: var(--spacing-3);
height: var(--spacing-3);
border-radius: var(--radius-round);
&[data-tone='success'] {
background: var(--accent-forest);
}
&[data-tone='warning'] {
background: var(--accent-amber);
}
&[data-tone='error'] {
background: var(--accent-cherry);
}
&[data-tone='info'] {
background: var(--accent-primary);
}
}
.chipLabel {
flex-shrink: 0;
}
.chipSeparator {
flex-shrink: 0;
color: var(--l2-foreground);
}
.chipValue {
display: inline-flex;
gap: var(--spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--accent-primary);
}
.chipKey {
flex-shrink: 0;
}
.toolName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-amber);
}
.toolCall,
.toolResult {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.toolCall {
border-radius: var(--radius-2);
transition: outline-color 0.3s ease;
outline: 1px solid transparent;
outline-offset: var(--spacing-1);
&.isHighlighted {
outline-color: var(--primary);
}
}
.toolLink {
display: inline-flex;
align-self: flex-start;
max-width: 100%;
min-width: 0;
padding: 0;
border: none;
border-radius: var(--radius-2);
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
&:hover {
.chip {
background: var(--l3-background);
}
.toolLinkIcon {
color: var(--primary);
}
}
}
.toolLinkIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.collapsible {
min-width: 0;
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
gap: var(--spacing-2);
padding: 0 !important;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
// Unboxed: the chevron sits right after the label.
:global(.ant-collapse-header-text) {
flex: none !important;
margin-inline-end: 0 !important;
color: var(--l2-foreground);
@include paragraph-base;
}
:global(.ant-collapse-expand-icon) {
height: auto !important;
padding-inline-start: 0 !important;
}
:global(.ant-collapse-content-box) {
display: flex;
flex-direction: column;
min-width: 0;
padding: var(--spacing-4) 0 0 !important;
}
&.boxed {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-header) {
padding: var(--spacing-3) var(--spacing-5) !important;
}
:global(.ant-collapse-header-text) {
flex: auto !important;
}
:global(.ant-collapse-content-box) {
padding: 0 var(--spacing-5) var(--spacing-4) !important;
}
// The block is the box; nested text drops its own.
.textBox {
padding: 0;
border: none;
background: none;
}
}
}
.collapsibleChevron {
display: flex;
color: var(--l2-foreground);
}
.jsonBlock {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.json {
position: relative;
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
cursor: auto;
:global(.pretty-view) {
padding: 0;
}
}
.jsonCollapsed {
overflow: hidden;
}
.jsonCopy {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
z-index: 1;
}

View File

@@ -0,0 +1,98 @@
import { Badge } from '@signozhq/ui/badge';
import { MessageType, ThreadMessage } from './types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import RoleLabel from './RoleLabel';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
messageType?: MessageType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function AIThreadMessage({
message,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, parts: messageParts, finish_reason: finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const isOutput = messageType === MessageType.Output;
const parts = (
<div className={styles.parts}>
{messageParts.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
messageParts.map((part, index) => (
<MessagePart
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' || role === 'developer' ? (
<CollapsibleBlock
label={roleLabel && <RoleLabel label={roleLabel} />}
testId="ai-message-system-toggle"
>
{parts}
</CollapsibleBlock>
) : (
<>
{(roleLabel || isOutput) && (
<div className={styles.roleHeader}>
{roleLabel && <RoleLabel label={roleLabel} />}
{isOutput && (
<Badge
variant="outlined"
color="secondary"
textTransform="none"
testId="ai-message-output-badge"
>
Output
</Badge>
)}
</div>
)}
{parts}
</>
)}
{finishReason && (
<PartChip
label="Finish Reason"
value={finishReason}
tone={tone}
testId="ai-message-finish-reason"
/>
)}
</div>
);
}
AIThreadMessage.defaultProps = {
messageType: MessageType.Input,
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -0,0 +1,54 @@
import { MouseEvent, ReactNode } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
const PANEL_KEY = 'content';
interface CollapsibleBlockProps {
label: ReactNode;
// Boxed: full-width bordered row with the chevron on the right.
isBoxed?: boolean;
testId?: string;
children: ReactNode;
}
function CollapsibleBlock({
label,
isBoxed = false,
testId,
children,
}: CollapsibleBlockProps): JSX.Element {
// Toggling must not count as a click on the message card; the body still does.
const stopHeaderClick = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
event.stopPropagation();
}
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={stopHeaderClick}>
<Collapse
ghost
className={cx(styles.collapsible, isBoxed && styles.boxed)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.collapsibleChevron} data-testid={testId}>
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</span>
)}
items={[{ key: PANEL_KEY, label, children }]}
/>
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

@@ -0,0 +1,39 @@
import { MouseEvent, useState } from 'react';
import AIMarkdown from './AIMarkdown';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
}
function ExpandableText({ text }: ExpandableTextProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<>
<AIMarkdown className={styles.text} content={visibleText} />
{text.length > MAX_TEXT_LENGTH && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-message-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</>
);
}
export default ExpandableText;

View File

@@ -0,0 +1,79 @@
import { MouseEvent, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { PrettyView } from 'periscope/components/PrettyView';
import { useResizeObserver } from 'hooks/useDimensions';
import styles from './AIThreadMessage.module.scss';
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
const NO_ACTIONS = { leaf: [], nested: [] };
// 10 tree rows: 18px line-height plus 2px padding top and bottom.
const COLLAPSED_MAX_HEIGHT = 10 * 22;
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
const treeRef = useRef<HTMLDivElement>(null);
const { height: treeHeight } = useResizeObserver(treeRef);
const isLong = treeHeight > COLLAPSED_MAX_HEIGHT;
const isClamped = isLong && !isExpanded;
const stopToggleClick = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
event.stopPropagation();
}
};
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<div className={styles.jsonBlock}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.json, isClamped && styles.jsonCollapsed)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
onClick={stopToggleClick}
data-testid="ai-json-block"
>
<span className={styles.jsonCopy}>
<CopyButton
value={text}
size={12}
ariaLabel="Copy JSON"
testId="ai-json-copy"
/>
</span>
<div ref={treeRef}>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
visibleActions={NO_ACTIONS}
/>
</div>
</div>
{isLong && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-json-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
}
export default JsonBlock;

View File

@@ -0,0 +1,105 @@
import { MessagePart as MessagePartType } from './types';
import JsonBlock from './JsonBlock';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
import {
isEmptyPart,
toDisplayText,
toToolCallKey,
truncateBlobContent,
} from './utils';
import styles from './AIThreadMessage.module.scss';
const EMPTY_PART = (
<span className={styles.empty} data-testid="ai-message-empty-part">
No content
</span>
);
interface MessagePartProps {
part: MessagePartType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function MessagePart({
part,
canLinkToToolCall,
onToolLinkClick,
}: MessagePartProps): JSX.Element {
if (isEmptyPart(part)) {
return EMPTY_PART;
}
switch (part.type) {
case 'text':
return <TextContent text={part.content} />;
case 'reasoning':
return <ThinkingPart content={part.content} />;
case 'compaction':
return (
<ThinkingPart
label="Compaction"
content={part.content ?? undefined}
testId="ai-message-compaction-toggle"
/>
);
case 'tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.arguments}
type={part.type}
/>
);
case 'server_tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.server_tool_call}
type={part.type}
/>
);
case 'tool_call_response':
case 'server_tool_call_response': {
const toolCallId = part.id ?? '';
const toolCallKey = toToolCallKey(
toolCallId,
part.type === 'server_tool_call_response',
);
return (
<ToolResultPart
toolCallId={toolCallId}
toolCallKey={toolCallKey}
type={part.type}
content={toDisplayText(
part.type === 'tool_call_response'
? part.response
: part.server_tool_call_response,
)}
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
onLinkClick={onToolLinkClick}
/>
);
}
case 'blob':
case 'file':
case 'uri':
return <JsonBlock data={truncateBlobContent(part)} />;
case 'generic':
return <TextContent text={toDisplayText(part.content)} />;
default:
return <TextContent text={toDisplayText(part)} />;
}
}
MessagePart.defaultProps = {
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import cx from 'classnames';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
// Plain: no chip background, for headers like a tool call's.
isPlain?: boolean;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
isPlain = false,
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span
className={cx(styles.chip, isPlain && styles.isPlain)}
data-testid={testId}
>
<span className={styles.dot} data-tone={tone} />
<span className={styles.chipLabel}>{label}</span>
{value !== undefined && (
<>
<span className={styles.chipSeparator}>—</span>
<span className={styles.chipValue}>{value}</span>
</>
)}
{children}
</span>
);
}
PartChip.defaultProps = {
value: undefined,
tone: 'success',
isPlain: false,
testId: undefined,
children: undefined,
};
export default PartChip;

View File

@@ -0,0 +1,18 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './AIThreadMessage.module.scss';
interface RoleLabelProps {
label: string;
}
/** Message role in the design's Paragraph/Base/400 style. */
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
return (
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
{label}
</Typography.Text>
);
}
export default RoleLabel;

View File

@@ -0,0 +1,27 @@
import { useMemo } from 'react';
import ExpandableText from './ExpandableText';
import JsonBlock from './JsonBlock';
import { parseJsonObject } from './utils';
import styles from './AIThreadMessage.module.scss';
interface TextContentProps {
text: string;
}
/** Renders JSON text as a tree, anything else as markdown. */
function TextContent({ text }: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} />
</div>
);
}
export default TextContent;

View File

@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from 'tests/test-utils';
import AIThreadMessage from '../AIThreadMessage';
import JsonBlock from '../JsonBlock';
import { MessageType } from '../types';
// jsdom has no layout, so the tree height JsonBlock measures is set per test.
let mockTreeHeight = 0;
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: mockTreeHeight,
}),
}));
describe('AIThreadMessage', () => {
it('shows the Output badge when the message has no role', () => {
render(
<AIThreadMessage
message={{ parts: [{ type: 'text', content: 'hi' }] }}
messageType={MessageType.Output}
/>,
);
expect(screen.getByTestId('ai-message-output-badge')).toBeInTheDocument();
});
it.each([
['blank text', { type: 'text' as const, content: ' ' }],
['empty generic', { type: 'generic' as const, content: '' }],
])('renders No content for a %s part', (_label, part) => {
render(<AIThreadMessage message={{ role: 'user', parts: [part] }} />);
expect(screen.getByTestId('ai-message-empty-part')).toHaveTextContent(
'No content',
);
});
});
describe('JsonBlock', () => {
it('collapses JSON taller than 10 rows behind Show more', () => {
mockTreeHeight = 300;
render(<JsonBlock data={{ a: 1 }} />);
const toggle = screen.getByTestId('ai-json-show-more');
expect(toggle).toHaveTextContent('Show more');
fireEvent.click(toggle);
expect(toggle).toHaveTextContent('Show less');
});
it('does not collapse JSON of 10 rows or fewer', () => {
mockTreeHeight = 220;
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.queryByTestId('ai-json-show-more')).not.toBeInTheDocument();
});
it('renders a copy button', () => {
render(<JsonBlock data={{ a: 1 }} />);
expect(screen.getByTestId('ai-json-copy')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,34 @@
import CollapsibleBlock from '../CollapsibleBlock';
import TextContent from '../TextContent';
import styles from '../AIThreadMessage.module.scss';
interface ThinkingPartProps {
content?: string;
label?: string;
testId?: string;
}
function ThinkingPart({
content,
label = 'Thinking',
testId = 'ai-message-thinking-toggle',
}: ThinkingPartProps): JSX.Element {
return (
<CollapsibleBlock label={label} isBoxed testId={testId}>
{content ? (
<TextContent text={content} />
) : (
<span className={styles.empty}> - </span>
)}
</CollapsibleBlock>
);
}
ThinkingPart.defaultProps = {
content: undefined,
label: 'Thinking',
testId: 'ai-message-thinking-toggle',
};
export default ThinkingPart;

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolCallPartProps {
id: string;
name: string;
args: unknown;
type: 'tool_call' | 'server_tool_call';
}
function ToolCallPart({
id,
name,
args,
type,
}: ToolCallPartProps): JSX.Element {
// Streamed calls can carry truncated, unparseable argument strings.
const json = useMemo(() => {
if (typeof args === 'string') {
return parseJsonObject(args);
}
return typeof args === 'object' && args !== null ? args : undefined;
}, [args]);
const toolCallAttr = {
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
};
return (
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
<PartChip
label={type}
tone="info"
isPlain
value={
<>
<span className={styles.chipKey}>name :</span>
<span className={styles.toolName}>{name}</span>
</>
}
/>
{json ? (
<JsonBlock data={json} />
) : (
<span className={styles.empty}> - </span>
)}
</div>
);
}
export default ToolCallPart;

View File

@@ -0,0 +1,80 @@
import { MouseEvent, useMemo } from 'react';
import { CornerUpRight } from '@signozhq/icons';
import PartChip from '../PartChip';
import TextContent from '../TextContent';
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolResultPartProps {
toolCallId: string;
toolCallKey: string;
type: 'tool_call_response' | 'server_tool_call_response';
name?: string;
content: string;
isError?: boolean;
isLinkEnabled: boolean;
onLinkClick?: (toolCallKey: string) => void;
}
function ToolResultPart({
toolCallId,
toolCallKey,
type,
name,
content,
isError,
isLinkEnabled,
onLinkClick,
}: ToolResultPartProps): JSX.Element {
// Short plain results fit in the chip; JSON or long ones render below it.
const isInline = useMemo(
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
[content],
);
const handleLinkClick = (event: MouseEvent): void => {
event.stopPropagation();
onLinkClick?.(toolCallKey);
};
const chip = (
<PartChip
label={type}
value={isInline ? content || '…' : name || toolCallId}
tone={isError ? 'error' : 'success'}
>
{isLinkEnabled && (
<CornerUpRight size={12} className={styles.toolLinkIcon} />
)}
</PartChip>
);
return (
<div className={styles.toolResult} data-testid="ai-tool-result">
{isLinkEnabled ? (
<button
type="button"
className={styles.toolLink}
onClick={handleLinkClick}
title="Go to tool call"
data-testid="ai-tool-result-link"
>
{chip}
</button>
) : (
chip
)}
{!isInline && <TextContent text={content} />}
</div>
);
}
ToolResultPart.defaultProps = {
name: undefined,
isError: false,
onLinkClick: undefined,
};
export default ToolResultPart;

View File

@@ -0,0 +1,57 @@
import type {
AiobservabilitytypesBlobPartDTO,
AiobservabilitytypesCompactionPartDTO,
AiobservabilitytypesFilePartDTO,
AiobservabilitytypesOutputMessageDTO,
AiobservabilitytypesReasoningPartDTO,
AiobservabilitytypesServerToolCallPartDTO,
AiobservabilitytypesServerToolCallResponsePartDTO,
AiobservabilitytypesTextPartDTO,
AiobservabilitytypesToolCallRequestPartDTO,
AiobservabilitytypesToolCallResponsePartDTO,
AiobservabilitytypesUriPartDTO,
} from 'api/generated/services/sigNoz.schemas';
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
type Part<T extends { type: string }> = Omit<T, 'type'> & {
type: `${T['type']}`;
};
// Keys the schema marks required may still be missing.
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
type: `${T['type']}`;
};
/** OTel GenAI message part; `text` content is always normalised to a string. */
export type MessagePart =
| Part<AiobservabilitytypesTextPartDTO>
| LoosePart<AiobservabilitytypesReasoningPartDTO>
| LoosePart<AiobservabilitytypesCompactionPartDTO>
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesBlobPartDTO>
| LoosePart<AiobservabilitytypesFilePartDTO>
| LoosePart<AiobservabilitytypesUriPartDTO>
| { type: 'generic'; content?: unknown };
export type MessagePartType = MessagePart['type'];
export type MediaMessagePart = Extract<
MessagePart,
{ type: 'blob' | 'file' | 'uri' }
>;
// role: system | developer | user | assistant | tool; any other value renders headerless.
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
export type ThreadMessage = Partial<
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
> & {
parts: MessagePart[];
};
export enum MessageType {
Input = 'input',
Output = 'output',
}

View File

@@ -0,0 +1,43 @@
import { RefObject, useCallback } from 'react';
import { TOOL_CALL_ID_ATTR } from './utils';
import styles from './AIThreadMessage.module.scss';
const HIGHLIGHT_MS = 1500;
interface ToolCallLinks {
canLinkToToolCall: (toolCallKey: string) => boolean;
onToolLinkClick: (toolCallKey: string) => void;
}
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
export function useToolCallLinks(
rootRef: RefObject<HTMLElement>,
toolCallKeys: Set<string>,
): ToolCallLinks {
const canLinkToToolCall = useCallback(
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
[toolCallKeys],
);
const onToolLinkClick = useCallback(
(toolCallKey: string): void => {
const target = rootRef.current?.querySelector<HTMLElement>(
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
);
if (!target) {
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add(styles.isHighlighted);
window.setTimeout(
() => target.classList.remove(styles.isHighlighted),
HIGHLIGHT_MS,
);
},
[rootRef],
);
return { canLinkToToolCall, onToolLinkClick };
}

View File

@@ -0,0 +1,96 @@
import { attempt, isError, isObjectLike } from 'lodash-es';
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
export const MAX_TEXT_LENGTH = 1000;
const MAX_BLOB_PREVIEW_LENGTH = 100;
export const MAX_INLINE_RESULT_LENGTH = 80;
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
const ROLE_LABELS: Record<string, string> = {
system: 'System',
developer: 'Developer',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
// Provider values the backend could not map fall back to info.
const FINISH_REASON_TONES: Record<string, MessageTone> = {
stop: 'success',
tool_call: 'success',
length: 'warning',
content_filter: 'warning',
compaction: 'info',
error: 'error',
};
/** Undefined for missing or unknown roles, which render headerless. */
export function getRoleLabel(role?: string): string | undefined {
return role ? ROLE_LABELS[role] : undefined;
}
export function getFinishReasonTone(reason?: string | null): MessageTone {
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const parsed: unknown = attempt(JSON.parse, text);
return isObjectLike(parsed) && !isError(parsed)
? (parsed as object)
: undefined;
}
export function toDisplayText(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
/** Text and generic parts with only whitespace have nothing to render. */
export function isEmptyPart(part: MessagePart): boolean {
if (part.type === 'text') {
return part.content.trim() === '';
}
if (part.type === 'generic') {
return toDisplayText(part.content).trim() === '';
}
return false;
}
/** Blob content is inline base64; only a prefix is shown. */
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
if (
part.type !== 'blob' ||
!part.content ||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
) {
return part;
}
return {
...part,
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
};
}
// Server and client calls can share an id, so links match on kind too.
export function toToolCallKey(id: string, isServer: boolean): string {
return `${isServer ? 'server' : 'client'}:${id}`;
}
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.parts.flatMap((part) =>
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
: [],
),
);
}

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;
}
@@ -16,7 +16,8 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<Tooltip title={tooltip}>
// Below the row: above it the tooltip covers the Overview / Thread tabs.
<Tooltip title={tooltip} side="bottom">
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>

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

@@ -0,0 +1,43 @@
@use '../../../../styles/scrollbar' as *;
.accordion {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
height: 40px;
padding: 0 var(--spacing-4) !important;
}
:global(.ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--l2-border);
}
:global(.ant-collapse-content-box) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.chevron {
display: flex;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,103 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import styles from './MessagesAccordion.module.scss';
const PANEL_KEY = 'messages';
interface MessagesAccordionProps extends Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
> {
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
return null;
}
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
return (
<section data-testid={testId}>
<Collapse
className={styles.accordion}
activeKey={isOpen ? [PANEL_KEY] : []}
onChange={(): void => setIsOpen((prev) => !prev)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
)}
items={[
{
key: PANEL_KEY,
label: (
<span className={styles.title}>
{icon}
{title}
</span>
),
extra: isOpen &&
canFormat && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
</span>
),
children: (
<MessagesSection
view={effectiveView}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
),
},
]}
/>
</section>
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -0,0 +1,7 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex-shrink: 0;
min-width: 0;
}

View File

@@ -0,0 +1,58 @@
import { useMemo, useRef } from 'react';
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
import styles from './SpanAIMessages.module.scss';
interface SpanAIMessagesProps {
span: ThreadSpan;
}
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
const data = useMemo(() => getSpanMessages(span), [span]);
const rootRef = useRef<HTMLDivElement>(null);
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
rootRef,
toolCallKeys,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
<MessagesAccordion
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesAccordion
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
export default SpanAIMessages;

View File

@@ -34,6 +34,15 @@
min-width: 0;
}
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
.tabAiMessages {
margin-bottom: 16px;
&:empty {
display: none;
}
}
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
// a true fixed footer flush with the panel's bottom edge — always visible
// regardless of how the body is scrolled.

View File

@@ -48,6 +48,7 @@ import Events from './Events/Events';
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -281,6 +282,7 @@ function SpanDetailsContent({
traceEndTime={traceEndTime}
/>
);
const aiMessages = <SpanAIMessages span={selectedSpan} />;
const eventsCount = selectedSpan.events?.length || 0;
const spanDetailTabs: TabsItemProps[] = [
@@ -291,6 +293,7 @@ function SpanDetailsContent({
children: (
<>
{isWide && summary}
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"
@@ -369,6 +372,7 @@ function SpanDetailsContent({
<>
<div className={styles.panelBody} ref={bodyRef}>
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
{!isWide && aiMessages}
<div className={styles.tabsSection}>
<Tabs

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,68 @@
import AIThreadMessage, {
AIThreadMessageProps,
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
type MessageProps = Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
>;
interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{isFormatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
// Messages have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
message={message}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))}
</div>
) : (
<RawMessages value={raw as RawMessagesValue} />
)}
</section>
);
}
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -0,0 +1,21 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<pre className={styles.raw} data-testid="span-raw-messages">
{value}
</pre>
);
}
export default RawMessages;

View File

@@ -0,0 +1,51 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.sectionTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
}
.messages {
display: flex;
flex-direction: column;
gap: var(--spacing-10);
min-width: 0;
}
.raw {
margin: 0;
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
font-family: var(--font-family-sf-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-18);
color: var(--l1-foreground);
white-space: pre-wrap;
word-break: break-all;
}
.missing {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-align: center;
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface SpanMessagesProps extends Pick<
AIThreadMessageProps,
'canLinkToToolCall' | 'onToolLinkClick'
> {
span: ThreadSpan;
view: ThreadView;
className?: string;
}
/** Input and output messages of one AI span, shared by Thread and Span Details. */
function SpanMessages({
span,
view,
className,
canLinkToToolCall,
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
data-testid="span-messages-empty"
>
This span has no input or output.
</div>
);
}
if (!isFormatted) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
/>
</div>
);
}
return (
<div className={cx(styles.root, className)}>
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
SpanMessages.defaultProps = {
className: undefined,
};
export default SpanMessages;

View File

@@ -0,0 +1,74 @@
import { render, screen } from 'tests/test-utils';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
import SpanMessages from '../SpanMessages';
// jsdom has no layout; report a JSON tree taller than the 10-row clamp.
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 0,
height: 300,
}),
}));
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
({ span_id: 's1', attributes: {}, ...overrides }) as unknown as ThreadSpan;
describe('SpanMessages', () => {
it('shows the empty state when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
} as Partial<ThreadSpan>);
render(<SpanMessages span={span} view={ThreadView.Formatted} />);
expect(screen.getByTestId('span-messages-empty')).toHaveTextContent(
'This span has no input or output.',
);
});
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders raw messages in JSON view', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
} as Partial<ThreadSpan>);
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
});
it('opens every key in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
},
} as Partial<ThreadSpan>);
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'x'.repeat(2000) },
]),
},
} as Partial<ThreadSpan>);
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('ai-json-show-more')).toHaveTextContent(
'Show more',
);
});
});

View File

@@ -0,0 +1,192 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../../utils/aiTraceUtils';
import SpanMessages from '../SpanMessages';
const makeSpan = (overrides: Partial<ThreadSpan>): ThreadSpan =>
({
span_id: 'span-1',
name: 'chat gpt-4o',
attributes: {},
...overrides,
}) as unknown as ThreadSpan;
const LARGE_ARGUMENTS = {
query: 'weather forecast',
locations: Array.from({ length: 12 }, (_, index) => ({
city: `City ${index + 1}`,
coordinates: { lat: 10 + index, lon: 20 + index },
units: 'metric',
})),
};
const RAW_INPUT = [
{
role: 'system',
parts: [{ type: 'text', content: 'You are a weather bot.' }],
},
{
role: 'user',
parts: [{ type: 'text', content: 'What is the weather in every city?' }],
},
];
const RAW_OUTPUT = [
{
role: 'assistant',
parts: [
{
type: 'tool_call',
id: 'call_1',
name: 'get_weather',
arguments: LARGE_ARGUMENTS,
},
],
finish_reason: 'tool_call',
},
];
const rawAttributes = {
[GEN_AI_KEYS.inputMessages]: JSON.stringify(RAW_INPUT),
[GEN_AI_KEYS.outputMessages]: JSON.stringify(RAW_OUTPUT),
};
const clickShowMore = async (canvasElement: HTMLElement): Promise<void> => {
const toggles =
await within(canvasElement).findAllByTestId('ai-json-show-more');
await toggles.reduce(
(previous, toggle) => previous.then(() => userEvent.click(toggle)),
Promise.resolve(),
);
};
// Input and output messages of one AI span, as rendered on a Thread card.
// Each story isolates one behaviour: the empty state, role-less output,
// empty parts, and JSON blocks (copy, Show more, initial key expansion).
const meta = {
title: 'Pages/Traces/Trace Details/Span Messages',
component: SpanMessages,
args: { view: ThreadView.Formatted, span: makeSpan({}) },
argTypes: {
view: { control: 'inline-radio', options: Object.values(ThreadView) },
span: { control: false },
},
decorators: [
(Story): JSX.Element => (
<div style={{ maxWidth: 720, padding: 24 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof SpanMessages>;
export default meta;
type Story = StoryObj<typeof meta>;
/** No formatted messages: the dashed "no input or output" box. */
export const NoInputOrOutput: Story = {};
/** JSON view with no raw attributes: the same empty box. */
export const NoInputOrOutputJson: Story = {
args: { view: ThreadView.Json },
};
/** Formatted view while only raw attributes exist: still the empty box. */
export const RawOnlyFormatted: Story = {
args: { span: makeSpan({ attributes: rawAttributes }) },
};
/** An output message with no role keeps its Output badge. */
export const OutputWithoutRole: Story = {
args: {
span: makeSpan({
formattedOutput: [
{
parts: [{ type: 'text', content: 'Answer from a role-less message.' }],
finish_reason: 'stop',
},
],
}),
},
};
/** Blank text and empty generic parts render "No content"; an unknown part renders as text. */
export const EmptyParts: Story = {
args: {
span: makeSpan({
formattedInput: [
{
role: 'user',
parts: [
{ type: 'text', content: ' ' },
{ type: 'generic', content: '' },
{ type: 'unknown_kind' } as never,
],
},
],
}),
},
};
/** Large tool-call JSON: keys closed, clamped behind Show more, with copy. */
export const LargeJsonPart: Story = {
args: {
span: makeSpan({
formattedOutput: [
{
role: 'assistant',
parts: [
{
type: 'tool_call',
id: 'call_1',
name: 'get_weather',
arguments: LARGE_ARGUMENTS,
},
],
finish_reason: 'tool_call',
},
],
}),
},
};
/** The large tool-call JSON after Show more. */
export const LargeJsonPartExpanded: Story = {
...LargeJsonPart,
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
};
/** Small JSON part: no Show more, copy still available. */
export const SmallJsonPart: Story = {
args: {
span: makeSpan({
formattedOutput: [
{
role: 'assistant',
parts: [
{
type: 'tool_call',
id: 'call_2',
name: 'get_time',
arguments: { tz: 'UTC' },
},
],
},
],
}),
},
};
/** JSON tab: every key open, clamped behind Show more, with copy. */
export const JsonView: Story = {
args: { view: ThreadView.Json, span: makeSpan({ attributes: rawAttributes }) },
};
/** The JSON tab after Show more. */
export const JsonViewExpanded: Story = {
...JsonView,
play: async ({ canvasElement }): Promise<void> => clickShowMore(canvasElement),
};

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,29 @@
.root {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 428px;
}
.emoji {
font-size: 32px;
line-height: 1;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.highlight {
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,43 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
import styles from './ThreadNoAiSpans.module.scss';
function ThreadNoAiSpans(): JSX.Element {
return (
<div className={styles.root} data-testid="thread-empty">
<div className={styles.message}>
<span className={styles.emoji} aria-hidden>
🚏
</span>
<div className={styles.paragraphs}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
</div>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
);
}
export default ThreadNoAiSpans;

View File

@@ -0,0 +1,23 @@
import { Skeleton } from '@signozhq/ui/skeleton';
import styles from './ThreadSpanList.module.scss';
interface ThreadPageLoaderProps {
testId: string;
}
// Mirrors a span card: a short message, then an output with a block and a chip.
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
return (
<div className={styles.pageLoader} data-testid={testId}>
<Skeleton active title={{ width: 48 }} paragraph={{ rows: 1 }} />
<Skeleton
active
title={{ width: 112 }}
paragraph={{ rows: 3, width: ['100%', '100%', 160] }}
/>
</div>
);
}
export default ThreadPageLoader;

View File

@@ -0,0 +1,29 @@
.card {
position: relative;
padding: var(--spacing-8);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
min-width: 0;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
&:focus-visible {
outline: 1px solid var(--primary);
outline-offset: var(--spacing-1);
}
&.isSelected::before {
content: '';
position: absolute;
top: -1px;
bottom: -1px;
left: -1px;
width: 2px;
border-radius: var(--radius-2) 0 0 var(--radius-2);
background: var(--primary-background);
}
}

View File

@@ -0,0 +1,67 @@
import { KeyboardEvent, memo, useMemo, useRef } from 'react';
import cx from 'classnames';
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
import SpanMessages from '../SpanMessages/SpanMessages';
import { ThreadSpan, ThreadView } from './types';
import { buildToolCallIndex } from './utils';
import styles from './ThreadSpanCard.module.scss';
interface ThreadSpanCardProps {
span: ThreadSpan;
view: ThreadView;
isSelected: boolean;
onSelect: (span: ThreadSpan) => void;
}
function ThreadSpanCard({
span,
view,
isSelected,
onSelect,
}: ThreadSpanCardProps): JSX.Element {
const cardRef = useRef<HTMLDivElement>(null);
// A result links only to a call in its own span.
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
cardRef,
toolCallKeys,
);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
// Keys pressed on the card's own controls stay with them.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(span);
}
};
return (
<div
ref={cardRef}
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- a <button> can't nest the card's own buttons
role="button"
tabIndex={0}
aria-pressed={isSelected}
aria-label={`Span ${span.name}`}
className={cx(styles.card, isSelected && styles.isSelected)}
onClick={(): void => onSelect(span)}
onKeyDown={handleKeyDown}
data-span-id={span.span_id}
data-testid="thread-span-card"
>
<SpanMessages
span={span}
view={view}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
export default memo(ThreadSpanCard);

View File

@@ -0,0 +1,80 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useTraceSummary } from '../TraceDetailsHeader/useTraceSummary';
import { ThreadSpan } from './types';
import styles from './TraceDetailsThread.module.scss';
const RIGHT_DOCK_MIN = 480;
const RIGHT_DOCK_MAX = 720;
const BOTTOM_DOCK_DEFAULT = 360;
const BOTTOM_DOCK_MIN = 200;
interface ThreadSpanDetailsProps {
panelState: DetailsPanelState;
span: ThreadSpan;
variant: SpanDetailVariant;
onVariantChange: (variant: SpanDetailVariant) => void;
}
function ThreadSpanDetails({
panelState,
span,
variant,
onVariantChange,
}: ThreadSpanDetailsProps): JSX.Element {
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const { data: traceSummary } = useTraceSummary(traceId);
const panel = (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={span}
variant={variant}
onVariantChange={onVariantChange}
traceStartTime={traceSummary?.startTimestampMillis}
traceEndTime={traceSummary?.endTimestampMillis}
/>
);
if (variant === SpanDetailVariant.DOCKED_RIGHT) {
return (
<ResizableBox
handle="left"
defaultWidth={width}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setWidth}
className={styles.rightDock}
>
{panel}
</ResizableBox>
);
}
if (variant === SpanDetailVariant.DOCKED) {
return (
<ResizableBox
handle="top"
defaultHeight={height}
minHeight={BOTTOM_DOCK_MIN}
onResize={setHeight}
className={styles.bottomDock}
>
{panel}
</ResizableBox>
);
}
return panel;
}
export default ThreadSpanDetails;

View File

@@ -0,0 +1,29 @@
@use '../../../styles/scrollbar' as *;
.list {
@include custom-scrollbar;
flex: 1;
min-height: 0;
margin-bottom: var(--spacing-4);
}
// Padding, not margin: Virtuoso measures item boxes.
.item {
padding: var(--spacing-6) var(--spacing-8) 0;
}
.footer {
padding-bottom: var(--spacing-6);
}
.pageLoader {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-8);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}

View File

@@ -0,0 +1,94 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import ThreadPageLoader from './ThreadPageLoader';
import { ThreadSpan } from './types';
import styles from './ThreadSpanList.module.scss';
// Virtuoso needs a positive index it can decrease as older spans are prepended.
const FIRST_ITEM_INDEX_BASE = 1_000_000;
const LOAD_AHEAD_PX = 200;
interface ThreadSpanListProps {
spans: ThreadSpan[];
spansAboveFirstPage: number;
initialSpanIndex?: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
renderSpan: (span: ThreadSpan) => ReactNode;
}
function ThreadSpanList({
spans,
spansAboveFirstPage,
initialSpanIndex,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
renderSpan,
}: ThreadSpanListProps): JSX.Element {
const handleEndReached = useCallback((): void => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleStartReached = useCallback((): void => {
if (hasPreviousPage && !isFetchingPreviousPage) {
void fetchPreviousPage();
}
}, [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage]);
const components = useMemo(
() => ({
Header: (): JSX.Element | null =>
hasPreviousPage || isFetchingPreviousPage ? (
<div className={styles.item}>
<ThreadPageLoader testId="thread-loading-previous" />
</div>
) : null,
Footer: (): JSX.Element => (
<div className={styles.footer}>
{isFetchingNextPage && (
<div className={styles.item}>
<ThreadPageLoader testId="thread-loading-next" />
</div>
)}
</div>
),
}),
[hasPreviousPage, isFetchingPreviousPage, isFetchingNextPage],
);
return (
<Virtuoso
className={styles.list}
data-testid="thread-span-list"
data={spans}
// 0 is allowed, negative is not (Virtuoso warns); the base fits 1,000,000 prepended spans.
firstItemIndex={FIRST_ITEM_INDEX_BASE - spansAboveFirstPage}
// Omitted, not undefined: an explicit undefined blanks the list, and 0 resets an early scroll.
{...(initialSpanIndex === undefined
? {}
: { initialTopMostItemIndex: initialSpanIndex })}
increaseViewportBy={LOAD_AHEAD_PX}
computeItemKey={(_, span): string => span.span_id}
itemContent={(_, span): JSX.Element => (
<div className={styles.item}>{renderSpan(span)}</div>
)}
startReached={handleStartReached}
endReached={handleEndReached}
components={components}
/>
);
}
export default ThreadSpanList;

View File

@@ -0,0 +1,32 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
padding: var(--spacing-16);
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
display: flex;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}

View File

@@ -0,0 +1,50 @@
import { Button } from '@signozhq/ui/button';
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
import styles from './ThreadSpanNotFound.module.scss';
interface ThreadSpanNotFoundProps {
onGoBack: () => void;
onShowThread: () => void;
}
function ThreadSpanNotFound({
onGoBack,
onShowThread,
}: ThreadSpanNotFoundProps): JSX.Element {
return (
<div className={styles.root} data-testid="thread-span-not-found">
<span className={styles.icon}>
<SearchX size={32} />
</span>
<span className={styles.title}>Span not found in this thread</span>
<span className={styles.description}>
The requested span isn&apos;t part of this trace&apos;s AI thread.
</span>
<div className={styles.actions}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<ArrowLeft size={14} />}
onClick={onGoBack}
testId="thread-span-not-found-go-back"
>
Go back
</Button>
<Button
variant="solid"
color="primary"
size="md"
prefix={<MessagesSquare size={14} />}
onClick={onShowThread}
testId="thread-span-not-found-show-thread"
>
Show thread
</Button>
</div>
</div>
);
}
export default ThreadSpanNotFound;

View File

@@ -0,0 +1,46 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { ThreadView } from './types';
const VIEW_OPTIONS = [
{ label: 'Formatted', value: ThreadView.Formatted },
{ label: 'JSON', value: ThreadView.Json },
];
interface ThreadViewToggleProps {
value: ThreadView;
onChange: (view: ThreadView) => void;
testId?: string;
}
function ThreadViewToggle({
value,
onChange,
testId,
}: ThreadViewToggleProps): JSX.Element {
const handleChange = (next: string): void => {
// Clicking the active item deselects it and reports ''.
if (next) {
onChange(next as ThreadView);
}
};
return (
<ToggleGroup
variant="outlined"
color="secondary"
type="single"
size="sm"
value={value}
onChange={handleChange}
items={VIEW_OPTIONS}
testId={testId}
/>
);
}
ThreadViewToggle.defaultProps = {
testId: 'thread-view-toggle',
};
export default ThreadViewToggle;

View File

@@ -0,0 +1,50 @@
.root {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
border-top: 1px solid var(--l2-border);
}
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--l2-border);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
.notice {
margin: var(--spacing-6) var(--spacing-8) var(--spacing-4);
}
.initialLoader {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6) var(--spacing-8);
}
.bottomDock {
display: flex;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--l2-border);
}
.rightDock {
display: flex;
flex-direction: column;
min-width: 0;
border-left: 1px solid var(--l2-border);
}

View File

@@ -0,0 +1,165 @@
import { useParams } from 'react-router-dom';
import { Callout } from '@signozhq/ui/callout';
import history, { hasInAppHistory } from 'lib/history';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailsTab } from '../constants';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
import ThreadNoAiSpans from './ThreadNoAiSpans';
import ThreadPageLoader from './ThreadPageLoader';
import ThreadSpanCard from './ThreadSpanCard';
import ThreadSpanDetails from './ThreadSpanDetails';
import ThreadSpanList from './ThreadSpanList';
import ThreadSpanNotFound from './ThreadSpanNotFound';
import ThreadViewToggle from './ThreadViewToggle';
import { InterestedSpanStatus } from './types';
import { getInterestedSpanStatus } from './utils';
import styles from './TraceDetailsThread.module.scss';
const INITIAL_LOADER_COUNT = 4;
function TraceDetailsThread(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const [, setTab] = useTraceDetailsTab();
const [view, setView] = useThreadView();
const {
interestedSpanId,
clearInterestedSpan,
selectedSpanId,
selectSpan,
panelState,
} = useThreadSpanSelection();
const {
spans,
spansAboveFirstPage,
isLoading,
isError,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
} = useTraceThread({ traceId: traceId || '', interestedSpanId });
const interestedSpanStatus = getInterestedSpanStatus(
interestedSpanId,
spans,
isLoading,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,
);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const spanDetails = panelState.isOpen && selectedSpan && (
<ThreadSpanDetails
panelState={panelState}
span={selectedSpan}
variant={spanDetailVariant}
onVariantChange={setSpanDetailVariant}
/>
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const renderBody = (): JSX.Element => {
if (isLoading) {
return (
<div className={styles.initialLoader} data-testid="thread-loading">
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<ThreadPageLoader key={i} testId="thread-loading-item" />
))}
</div>
);
}
if (interestedSpanStatus === InterestedSpanStatus.NotFound) {
return (
<ThreadSpanNotFound
onGoBack={(): void => {
// go back to the previous page
if (hasInAppHistory()) {
history.goBack();
} else {
void setTab(TraceDetailsTab.Overview);
}
}}
onShowThread={clearInterestedSpan}
/>
);
}
if (hasNoThread) {
return <ThreadNoAiSpans />;
}
return (
<>
{interestedSpanStatus === InterestedSpanStatus.NoMessages && (
<Callout
type="info"
size="small"
className={styles.notice}
title="The requested span has no input or output messages. Showing the rest of the thread."
testId="thread-anchor-no-messages"
/>
)}
<ThreadSpanList
key={interestedSpanId}
spans={spans}
spansAboveFirstPage={spansAboveFirstPage}
initialSpanIndex={
interestedSpanIndex === -1 ? undefined : interestedSpanIndex
}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isFetchingNextPage={isFetchingNextPage}
isFetchingPreviousPage={isFetchingPreviousPage}
fetchNextPage={fetchNextPage}
fetchPreviousPage={fetchPreviousPage}
renderSpan={(span): JSX.Element => (
<ThreadSpanCard
span={span}
view={view}
isSelected={span.span_id === selectedSpanId}
onSelect={selectSpan}
/>
)}
/>
</>
);
};
return (
<div className={styles.root} data-testid="trace-details-thread">
<div className={styles.content}>
{!hasNoThread && interestedSpanStatus !== InterestedSpanStatus.NotFound && (
<div className={styles.toolbar}>
<ThreadViewToggle
value={view}
onChange={(next): void => {
void setView(next);
}}
/>
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
</div>
)}
{renderBody()}
{isBottomDocked && spanDetails}
</div>
{!isBottomDocked && spanDetails}
</div>
);
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,91 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getTraceThread } from 'api/generated/services/tracedetail';
import { useTraceThread } from '../useTraceThread';
jest.mock('api/generated/services/tracedetail', () => ({
getGetTraceThreadQueryKey: (...args: unknown[]): unknown[] => [
'thread',
...args,
],
getTraceThread: jest.fn(),
}));
const SPANS_PER_PAGE = 3;
const LAST_PAGE = 2;
// Page n is reached with `before: c{n+1}` or `after: c{n-1}`; the first request returns the last page.
function page(n: number): unknown {
return {
data: {
spans: Array.from({ length: SPANS_PER_PAGE }, (_, i) => ({
span_id: `span-${n}-${i}`,
})),
prevCursor: n > 0 ? `c${n}` : undefined,
nextCursor: n < LAST_PAGE ? `c${n}` : undefined,
},
};
}
function cursorPage(cursor: string, offset: number): number {
return Number(cursor.slice(1)) + offset;
}
function renderThread(): {
result: { current: ReturnType<typeof useTraceThread> };
queryClient: QueryClient;
} {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(
() => useTraceThread({ traceId: 'trace', interestedSpanId: 'span' }),
{ wrapper },
);
return { result, queryClient };
}
describe('useTraceThread', () => {
beforeEach(() => {
(getTraceThread as jest.Mock).mockImplementation(
(_path, { before, after }: { before?: string; after?: string }) => {
if (before) {
return Promise.resolve(page(cursorPage(before, -1)));
}
if (after) {
return Promise.resolve(page(cursorPage(after, 1)));
}
return Promise.resolve(page(LAST_PAGE));
},
);
});
it('counts spans prepended above the first page', async () => {
const { result } = renderThread();
await waitFor(() => expect(result.current.spans).toHaveLength(3));
expect(result.current.spansAboveFirstPage).toBe(0);
await act(() => result.current.fetchPreviousPage());
await act(() => result.current.fetchPreviousPage());
expect(result.current.spans).toHaveLength(9);
expect(result.current.spansAboveFirstPage).toBe(6);
});
it('keeps spansAboveFirstPage stable across a refetch', async () => {
const { result, queryClient } = renderThread();
await waitFor(() => expect(result.current.spans).toHaveLength(3));
await act(() => result.current.fetchPreviousPage());
await act(() => result.current.fetchPreviousPage());
await act(() => queryClient.refetchQueries());
expect(result.current.spans).toHaveLength(9);
expect(result.current.spansAboveFirstPage).toBe(6);
});
});

View File

@@ -0,0 +1,80 @@
import { useCallback, useMemo, useState } from 'react';
import { useDetailsPanel } from 'components/DetailsPanel';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { ThreadSpan } from '../types';
const SPAN_ID_PARAM = 'spanId';
interface ThreadSpanSelection {
// The spanId the thread was opened with; it shapes the first page fetched.
interestedSpanId?: string;
clearInterestedSpan: () => void;
selectedSpanId?: string;
selectSpan: (span: ThreadSpan) => void;
panelState: DetailsPanelState;
}
export function useThreadSpanSelection(): ThreadSpanSelection {
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const selectedSpanId = urlQuery.get(SPAN_ID_PARAM) || undefined;
const [interestedSpanId, setInterestedSpanId] = useState(selectedSpanId);
const setSpanIdParam = useCallback(
(spanId?: string): void => {
const params = getUnstableCurrentSearchParams();
if (spanId) {
params.set(SPAN_ID_PARAM, spanId);
} else {
params.delete(SPAN_ID_PARAM);
}
safeNavigate({ search: params.toString() }, { replace: true });
},
[safeNavigate],
);
const handleClose = useCallback(
(): void => setSpanIdParam(undefined),
[setSpanIdParam],
);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleClose,
});
const { open: openPanel } = panelState;
const selectSpan = useCallback(
(span: ThreadSpan): void => {
setSpanIdParam(span.span_id);
openPanel();
},
[setSpanIdParam, openPanel],
);
const clearInterestedSpan = useCallback((): void => {
setInterestedSpanId(undefined);
setSpanIdParam(undefined);
}, [setSpanIdParam]);
return useMemo(
() => ({
interestedSpanId,
clearInterestedSpan,
selectedSpanId,
selectSpan,
panelState,
}),
[
interestedSpanId,
clearInterestedSpan,
selectedSpanId,
selectSpan,
panelState,
],
);
}

View File

@@ -0,0 +1,12 @@
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
import { ThreadView } from '../types';
export function useThreadView(): UseQueryStateReturn<ThreadView, ThreadView> {
return useQueryState(
'view',
parseAsStringEnum(Object.values(ThreadView)).withDefault(
ThreadView.Formatted,
),
);
}

View File

@@ -0,0 +1,109 @@
import { useMemo, useRef } from 'react';
import { useInfiniteQuery } from 'react-query';
import {
getGetTraceThreadQueryKey,
getTraceThread,
} from 'api/generated/services/tracedetail';
import type { GetTraceThreadParams } from 'api/generated/services/sigNoz.schemas';
import { ThreadSpan } from '../types';
import { isNotFoundError, toThreadSpan } from '../utils';
const THREAD_PAGE_LIMIT = 100;
const MAX_RETRIES = 3;
const STALE_TIME_MS = 5 * 60 * 1000;
type PageParam = Pick<GetTraceThreadParams, 'after' | 'before'>;
interface UseTraceThreadOptions {
traceId: string;
interestedSpanId?: string;
}
interface UseTraceThreadResult {
spans: ThreadSpan[];
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
export function useTraceThread({
traceId,
interestedSpanId,
}: UseTraceThreadOptions): UseTraceThreadResult {
const query = useInfiniteQuery({
queryKey: getGetTraceThreadQueryKey(
{ traceID: traceId },
{ spanId: interestedSpanId },
),
queryFn: ({ pageParam, signal }) => {
const { after, before } = (pageParam ?? {}) as PageParam;
return getTraceThread(
{ traceID: traceId },
{
limit: THREAD_PAGE_LIMIT,
after,
before,
// The interested span only shapes the first page; cursors drive the rest.
spanId: after || before ? undefined : interestedSpanId,
},
signal,
);
},
getNextPageParam: (lastPage): PageParam | undefined =>
lastPage.data.nextCursor ? { after: lastPage.data.nextCursor } : undefined,
getPreviousPageParam: (firstPage): PageParam | undefined =>
firstPage.data.prevCursor
? { before: firstPage.data.prevCursor }
: undefined,
// An unknown interested span is a 404; retrying won't find it.
retry: (failureCount, error): boolean =>
!isNotFoundError(error) && failureCount < MAX_RETRIES,
enabled: !!traceId,
refetchOnWindowFocus: false,
staleTime: STALE_TIME_MS,
});
const spans = useMemo(
() =>
query.data?.pages.flatMap((page) => page.data.spans.map(toThreadSpan)) ?? [],
[query.data],
);
// Index of the first span shown on mount, so it grows only on prepend (Virtuoso's firstItemIndex).
// Not from pageParams: a react-query v3 refetch rebuilds later pages with `after` cursors.
const anchorRef = useRef<{ key: string; spanId: string }>();
const anchorKey = `${traceId}:${interestedSpanId ?? ''}`;
const spansAboveFirstPage = useMemo(() => {
if (spans.length === 0) {
return 0;
}
if (anchorRef.current?.key !== anchorKey) {
anchorRef.current = { key: anchorKey, spanId: spans[0].span_id };
}
const { spanId } = anchorRef.current;
return Math.max(
0,
spans.findIndex((span) => span.span_id === spanId),
);
}, [spans, anchorKey]);
return {
spans,
spansAboveFirstPage,
isLoading: query.isLoading,
isError: query.isError,
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,
isFetchingPreviousPage: query.isFetchingPreviousPage,
fetchNextPage: query.fetchNextPage,
fetchPreviousPage: query.fetchPreviousPage,
};
}

View File

@@ -0,0 +1,152 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest } from 'msw';
import { choiceControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
scenarioSpanCount,
STORY_TRACE_ID,
} from '../../stories/__story_mockdata__/traceDetails';
import { traceDetailsMocks } from '../../stories/TraceDetailsV3.stories.mocks';
import { ThreadView } from '../types';
import {
SECOND_PAGE_CURSOR,
SPAN_WITHOUT_MESSAGES,
THREAD_PAGINGS,
THREAD_SCENARIOS,
type ThreadPaging,
type ThreadScenario,
threadSpanIdAt,
traceThreadResponse,
} from './__story_mockdata__/traceThread';
const MESSAGES = 'Thread · messages';
const NAVIGATION = 'Thread · navigation';
const THREAD_VIEWS = [ThreadView.Formatted, ThreadView.Json] as const;
const ANCHORS = [
'none',
'span-in-thread',
'span-without-messages',
'span-not-found',
] as const;
type Anchor = (typeof ANCHORS)[number];
const TRACE_AGE_MS = 2 * 60 * 1000;
const interestedSpanId = (anchor: Anchor): string | undefined => {
switch (anchor) {
case 'span-in-thread':
return threadSpanIdAt(1);
case 'span-without-messages':
return SPAN_WITHOUT_MESSAGES;
case 'span-not-found':
return 'deadbeefdeadbeef';
default:
return undefined;
}
};
/** The header, and the span panel a card opens, come from the overview's mocks. */
const overviewValues = traceDetailsMocks.read({
scenario: 'ai',
spans: scenarioSpanCount('ai'),
spanPanel: false,
});
export const traceThreadMocks = defineStoryMocks({
controls: {
scenario: choiceControl<ThreadScenario>('Scenario', {
group: MESSAGES,
description:
'The spans the thread returns: a tool-calling conversation, one card per message part type, every role and finish reason, spans the backend could not format (raw attributes only), or none at all.',
options: THREAD_SCENARIOS,
value: 'conversation',
}),
view: choiceControl<ThreadView>('View', {
group: MESSAGES,
description: 'Formatted messages, or the raw gen_ai.*.messages JSON.',
options: THREAD_VIEWS,
value: ThreadView.Formatted,
}),
anchor: choiceControl<Anchor>('Opened on span', {
group: NAVIGATION,
description:
'The spanId the tab opens with. A span in the thread scrolls to it and opens the span panel; one the backend dropped for lacking messages shows the notice above the thread; one that is not in the trace answers 404.',
options: ANCHORS,
value: 'none',
}),
paging: choiceControl<ThreadPaging>('Paging', {
group: NAVIGATION,
description:
'Whether the thread arrives in one page, in two (the second loads as the list end scrolls into view), or with a second page that never answers.',
options: THREAD_PAGINGS,
value: 'single-page',
}),
},
handlers: (values, response) => {
const traceStart = Date.now() - TRACE_AGE_MS;
return [
rest.get(
'http://localhost/api/v1/traces/:traceId/thread',
(req, res, ctx) => {
const { searchParams } = req.url;
if (values.anchor === 'span-not-found' && searchParams.get('spanId')) {
return res(
ctx.status(404),
ctx.json({
status: 'error',
error: { code: 'not_found', message: 'span not found' },
}),
);
}
if (
values.paging === 'next-page-pending' &&
searchParams.get('after') === SECOND_PAGE_CURSOR
) {
return res(ctx.delay('infinite'));
}
return undefined;
},
),
rest.get(
'http://localhost/api/v1/traces/:traceId/thread',
response.json((req) =>
traceThreadResponse({
scenario: values.scenario,
paging: values.paging,
traceStart,
after: req.url.searchParams.get('after'),
}),
),
),
...(traceDetailsMocks.handlers?.(overviewValues, response) ?? []),
];
},
config: (values) => {
const params = new URLSearchParams({ tab: 'thread' });
if (values.view === ThreadView.Json) {
params.set('view', ThreadView.Json);
}
const spanId = interestedSpanId(values.anchor);
if (spanId) {
params.set('spanId', spanId);
}
return { route: `/trace/${STORY_TRACE_ID}?${params.toString()}` };
},
});
/** The thread renders once its first page resolves, which outlasts the 1s default. */
export const untilThreadLoaded = { timeout: 15_000 };

View File

@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import TraceDetailsV3 from '../../index';
import { ThreadView } from '../types';
import {
traceThreadMocks,
untilThreadLoaded,
} from './TraceDetailsThread.stories.mocks';
type TraceThreadArgs = PageStoryArgs<typeof traceThreadMocks>;
const pageStory = storyMocks(traceThreadMocks, { layout: 'app' });
/**
* The Thread tab of a trace: its AI spans as cards of input and output
* messages, in OTel GenAI parts, with a Formatted / JSON switch, tool results
* linking back to their calls, and the span panel a card opens. Driven by
* `GET /api/v1/traces/{id}/thread`, paged by cursor.
*
* Route: `/trace/:id?tab=thread`, with `view` and `spanId` from the controls.
*/
const meta = {
title: 'Pages/Traces/Trace Details/Thread',
tags: ['play'],
component: TraceDetailsV3,
// The page reads the trace id out of the pathname, so it renders under its
// own route rather than being mounted on its own.
render: (): JSX.Element => (
<Route path={ROUTES.TRACE_DETAIL} component={TraceDetailsV3} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<TraceThreadArgs>;
export default meta;
type Story = StoryObj<TraceThreadArgs>;
const clickAll = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const toggles = await within(canvasElement).findAllByTestId(
testId,
undefined,
untilThreadLoaded,
);
await toggles.reduce(
(previous, toggle) => previous.then(() => userEvent.click(toggle)),
Promise.resolve(),
);
};
/**
* A tool-calling conversation: the model reasons and calls a weather tool, the
* tool span returns its result, and a second call answers in markdown. The
* system prompt is collapsed and each tool result links back to its call.
*/
export const Default: Story = {};
/** The same conversation as the raw `gen_ai.*.messages` JSON of each span. */
export const Json: Story = {
args: { view: ThreadView.Json },
};
/** The system prompt opened from its collapsed line. */
export const SystemPromptExpanded: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickAll(canvasElement, 'ai-message-system-toggle');
},
};
/**
* One card per message part type: markdown and long text, reasoning with and
* without content, compaction, tool calls with object, truncated and missing
* arguments, inline and block tool results (one whose call is not in its span),
* server tool calls (one sharing its id with a client call), blob / file / uri
* media, and generic parts.
*/
export const EveryPart: Story = {
args: { scenario: 'every-part' },
};
/** Every part type with reasoning, compaction and long text expanded. */
export const EveryPartExpanded: Story = {
args: { scenario: 'every-part' },
play: async ({ canvasElement }): Promise<void> => {
await clickAll(canvasElement, 'ai-message-thinking-toggle');
await clickAll(canvasElement, 'ai-message-compaction-toggle');
await clickAll(canvasElement, 'ai-message-show-more');
},
};
/** Every part type as raw JSON. */
export const EveryPartJson: Story = {
args: { scenario: 'every-part', view: ThreadView.Json },
};
/** A tool result's link followed to its call in the same span, highlighted. */
export const ToolCallLinkFollowed: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [link] = await within(canvasElement).findAllByTestId(
'ai-tool-result-link',
undefined,
untilThreadLoaded,
);
await userEvent.click(link);
},
};
/**
* Every role (system and developer collapsed, a message with no role rendered
* headerless, one with no parts) and every finish reason: `stop` and
* `tool_call` green, `length` and `content_filter` amber, `error` red and
* `compaction` blue.
*/
export const RolesAndFinishReasons: Story = {
args: { scenario: 'roles-and-finish-reasons' },
};
/**
* Spans the backend could not format: messages rebuilt from the raw
* attributes, and a tool span with none at all.
*/
export const RawAttributes: Story = {
args: { scenario: 'raw-attributes' },
};
/** A trace with no AI spans, and the link to the LLM observability docs. */
export const NoAiSpans: Story = {
args: { scenario: 'no-ai-spans' },
};
/** The thread mid-fetch. */
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** Opened from a span link: scrolled to that span, with its panel open. */
export const OpenedOnSpan: Story = {
args: { anchor: 'span-in-thread' },
};
/**
* Opened on a span the backend dropped for having no messages: the rest of the
* thread under a notice.
*/
export const OpenedOnSpanWithoutMessages: Story = {
args: { anchor: 'span-without-messages' },
};
/** Opened on a span that is not in the trace, with the way back and to the thread. */
export const SpanNotFound: Story = {
args: { anchor: 'span-not-found' },
// The deliberate 404 is the state under test.
parameters: { allowConsoleErrors: true },
};
/** The first page shown and the next one still loading below it. */
export const NextPageLoading: Story = {
args: { scenario: 'every-part', paging: 'next-page-pending' },
};

View File

@@ -0,0 +1,488 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
type AiobservabilitytypesChatMessageDTO,
AiobservabilitytypesBlobPartDTOType,
AiobservabilitytypesCompactionPartDTOType,
AiobservabilitytypesFilePartDTOType,
type AiobservabilitytypesOutputMessageDTO,
type AiobservabilitytypesPartDTO,
AiobservabilitytypesReasoningPartDTOType,
AiobservabilitytypesServerToolCallPartDTOType,
AiobservabilitytypesServerToolCallResponsePartDTOType,
AiobservabilitytypesTextPartDTOType,
AiobservabilitytypesToolCallRequestPartDTOType,
AiobservabilitytypesToolCallResponsePartDTOType,
AiobservabilitytypesUriPartDTOType,
type GetTraceThread200,
type SpantypesThreadSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { STORY_TRACE_ID } from '../../../stories/__story_mockdata__/traceDetails';
export const THREAD_SCENARIOS = [
'conversation',
'every-part',
'roles-and-finish-reasons',
'raw-attributes',
'no-ai-spans',
] as const;
export type ThreadScenario = (typeof THREAD_SCENARIOS)[number];
export const THREAD_PAGINGS = [
'single-page',
'two-pages',
'next-page-pending',
] as const;
export type ThreadPaging = (typeof THREAD_PAGINGS)[number];
export const SECOND_PAGE_CURSOR = 'page-2';
/** A span id the thread never returns, for an anchor the backend dropped. */
export const SPAN_WITHOUT_MESSAGES = 'ffffffffffffffff';
type Part = AiobservabilitytypesPartDTO;
type InputMessage = AiobservabilitytypesChatMessageDTO;
type OutputMessage = AiobservabilitytypesOutputMessageDTO;
const text = (content: string): Part => ({
type: AiobservabilitytypesTextPartDTOType.text,
content,
});
const reasoning = (content: string): Part => ({
type: AiobservabilitytypesReasoningPartDTOType.reasoning,
content,
});
const toolCall = (id: string, name: string, args: unknown): Part => ({
type: AiobservabilitytypesToolCallRequestPartDTOType.tool_call,
id,
name,
arguments: args,
});
const toolResult = (id: string, response: unknown): Part => ({
type: AiobservabilitytypesToolCallResponsePartDTOType.tool_call_response,
id,
response,
});
const input = (role: string, parts: Part[]): InputMessage => ({ role, parts });
const output = (
role: string,
parts: Part[],
finishReason?: string,
): OutputMessage => ({ role, parts, finish_reason: finishReason });
interface ThreadSpanTemplate {
name: string;
operation: 'chat' | 'execute_tool' | 'invoke_agent';
input?: InputMessage[];
output?: OutputMessage[];
/** Raw `gen_ai.*.messages`, for spans the backend could not format. */
rawInput?: unknown;
rawOutput?: unknown;
}
const WEATHER_CALL = 'call_aA3FJchumEvBrIxxheORri8j';
const WEATHER = { city: 'Bengaluru', unit: 'c' };
const WEATHER_RESULT = {
city: 'Bengaluru',
unit: 'c',
temperature: 27,
condition: 'partly cloudy',
};
const QUESTION =
'What is the weather in Bengaluru in celsius, and do I need an umbrella?';
const SYSTEM_PROMPT =
'You are a travel assistant. Use the tools you have before answering, and keep answers short.';
const CONVERSATION: ThreadSpanTemplate[] = [
{
name: 'chat gpt-4o-mini',
operation: 'chat',
input: [
input('system', [text(SYSTEM_PROMPT)]),
input('user', [text(QUESTION)]),
],
output: [
output(
'assistant',
[
reasoning(
'The user wants current conditions. I should call get_current_weather before answering.',
),
toolCall(WEATHER_CALL, 'get_current_weather', WEATHER),
],
'tool_call',
),
],
},
{
name: 'execute_tool get_current_weather',
operation: 'execute_tool',
input: [
input('assistant', [toolCall(WEATHER_CALL, 'get_current_weather', WEATHER)]),
],
output: [output('tool', [toolResult(WEATHER_CALL, WEATHER_RESULT)])],
},
{
name: 'chat gpt-4o-mini',
operation: 'chat',
input: [
input('user', [text(QUESTION)]),
input('assistant', [toolCall(WEATHER_CALL, 'get_current_weather', WEATHER)]),
input('tool', [toolResult(WEATHER_CALL, WEATHER_RESULT)]),
],
output: [
output(
'assistant',
[
text(
'**Bengaluru right now:** 27 °C and partly cloudy.\n\n- No rain in the forecast for the next few hours.\n- A light umbrella is still worth carrying in the evening.',
),
],
'stop',
),
],
},
];
const LONG_ANSWER = Array.from(
{ length: 14 },
(_, i) =>
`${i + 1}. Step ${i + 1} of the itinerary: leave the hotel, take the metro two stops, and walk the last few hundred metres to the next place on the list.`,
).join('\n');
const EVERY_PART: ThreadSpanTemplate[] = [
{
name: 'chat · text',
operation: 'chat',
input: [
input('user', [text('Format the plan as markdown, with a code sample.')]),
],
output: [
output(
'assistant',
[
text(
'## Plan\n\nUse `get_current_weather` first:\n\n```json\n{ "city": "Bengaluru", "unit": "c" }\n```',
),
text(LONG_ANSWER),
],
'stop',
),
],
},
{
name: 'chat · reasoning',
operation: 'chat',
output: [
output(
'assistant',
[
reasoning('Two cities were asked for, so two tool calls are needed.'),
{ type: AiobservabilitytypesReasoningPartDTOType.reasoning, content: '' },
],
'stop',
),
],
},
{
name: 'chat · compaction',
operation: 'chat',
input: [
input('assistant', [
{
type: AiobservabilitytypesCompactionPartDTOType.compaction,
id: 'cmp_1',
content:
'Earlier turns summarised: the user is planning a weekend in Bengaluru.',
},
{ type: AiobservabilitytypesCompactionPartDTOType.compaction, id: 'cmp_2' },
]),
],
},
{
name: 'chat · tool calls',
operation: 'chat',
output: [
output(
'assistant',
[
toolCall('call_object_args', 'get_current_weather', WEATHER),
toolCall('call_streamed_args', 'get_current_weather', '{"city": "Beng'),
toolCall('call_no_args', 'list_cities', undefined),
],
'tool_call',
),
],
},
{
name: 'execute_tool · tool results',
operation: 'execute_tool',
input: [
input('assistant', [
toolCall('call_object_args', 'get_current_weather', WEATHER),
toolCall('call_streamed_args', 'get_current_weather', '{"city": "Beng'),
toolCall('call_no_args', 'list_cities', undefined),
]),
input('tool', [
toolResult('call_object_args', '27 °C'),
toolResult('call_streamed_args', WEATHER_RESULT),
toolResult(
'call_no_args',
'Bengaluru, Mumbai, Delhi, Chennai, Kolkata, Hyderabad, Pune, Ahmedabad, Jaipur, Lucknow',
),
toolResult('call_not_in_span', 'A result whose call is not in this span'),
]),
],
},
{
name: 'chat · server tools',
operation: 'chat',
// A client call sharing the server call's id, rendered first.
input: [
input('assistant', [
toolCall('srv_search_1', 'lookup_city', { city: 'Bengaluru' }),
]),
input('tool', [toolResult('srv_search_1', 'Bengaluru, India')]),
],
output: [
output(
'assistant',
[
{
type: AiobservabilitytypesServerToolCallPartDTOType.server_tool_call,
id: 'srv_search_1',
name: 'web_search',
server_tool_call: {
type: 'web_search',
query: 'Bengaluru weather today',
},
},
{
type:
AiobservabilitytypesServerToolCallResponsePartDTOType.server_tool_call_response,
id: 'srv_search_1',
server_tool_call_response: {
type: 'web_search',
results: [
{ title: 'Bengaluru forecast', url: 'https://example.com/weather' },
],
},
},
],
'stop',
),
],
},
{
name: 'chat · media',
operation: 'chat',
input: [
input('user', [
{
type: AiobservabilitytypesBlobPartDTOType.blob,
modality: 'image',
mime_type: 'image/png',
content: 'iVBORw0KGgo'.repeat(40),
},
{
type: AiobservabilitytypesFilePartDTOType.file,
modality: 'document',
mime_type: 'application/pdf',
file_id: 'file-itinerary-pdf',
},
{
type: AiobservabilitytypesUriPartDTOType.uri,
modality: 'image',
uri: 'https://example.com/map.png',
},
]),
],
},
{
name: 'chat · generic',
operation: 'chat',
output: [
output(
'assistant',
[
{
type: 'generic',
content: 'A part the formatter did not recognise, kept as text.',
},
{
type: 'generic',
content: {
type: 'tool-call',
toolName: 'get_current_weather',
args: WEATHER,
},
},
],
'stop',
),
],
},
];
const ROLES_AND_FINISH_REASONS: ThreadSpanTemplate[] = [
{
name: 'chat · roles',
operation: 'chat',
input: [
input('system', [text(SYSTEM_PROMPT)]),
input('developer', [text('Answer in metric units.')]),
input('user', [text(QUESTION)]),
input('assistant', [text('Checking the weather now.')]),
input('tool', [toolResult(WEATHER_CALL, WEATHER_RESULT)]),
input('', [text('A message with no role renders without a header.')]),
input('user', []),
],
},
{
name: 'chat · finish reasons',
operation: 'chat',
output: [
output('assistant', [text('Finished normally.')], 'stop'),
output('assistant', [text('Stopped to call a tool.')], 'tool_call'),
output('assistant', [text('Cut off at the token limit…')], 'length'),
output(
'assistant',
[text('Blocked by the content filter.')],
'content_filter',
),
output('assistant', [text('The provider returned an error.')], 'error'),
output('assistant', [text('The context was compacted.')], 'compaction'),
],
},
];
/** What the backend returns today for a Vercel AI SDK span it cannot format. */
const RAW_ATTRIBUTES: ThreadSpanTemplate[] = [
{
name: 'ai.streamText.doStream',
operation: 'chat',
rawInput: [{ role: 'user', content: [{ type: 'text', text: QUESTION }] }],
rawOutput: [
{
type: 'tool-call',
toolCallId: WEATHER_CALL,
toolName: 'get_current_weather',
args: WEATHER,
},
],
},
{ name: 'ai.toolCall', operation: 'execute_tool' },
];
const SCENARIO_TEMPLATES: Record<ThreadScenario, ThreadSpanTemplate[]> = {
conversation: CONVERSATION,
'every-part': EVERY_PART,
'roles-and-finish-reasons': ROLES_AND_FINISH_REASONS,
'raw-attributes': RAW_ATTRIBUTES,
'no-ai-spans': [],
};
const PARENT_SPAN_ID = 'a3911d2fd3180f00';
export const threadSpanIdAt = (index: number): string =>
`53d3b6e6de50${(0x5cb6 + index).toString(16).padStart(4, '0')}`;
const toSpan = (
template: ThreadSpanTemplate,
index: number,
traceStart: number,
): SpantypesThreadSpanDTO => {
const attributes: Record<string, unknown> = {
'gen_ai.operation.name': template.operation,
'gen_ai.provider.name': 'openai',
};
if (template.operation === 'chat') {
attributes['gen_ai.request.model'] = 'gpt-4o-mini';
attributes['gen_ai.usage.input_tokens'] = 71 + index * 12;
attributes['gen_ai.usage.output_tokens'] = 22 + index * 5;
}
if (template.operation === 'execute_tool') {
attributes['gen_ai.tool.name'] = 'get_current_weather';
}
const rawInput = template.rawInput ?? template.input;
const rawOutput = template.rawOutput ?? template.output;
if (rawInput) {
attributes['gen_ai.input.messages'] = JSON.stringify(rawInput);
}
if (rawOutput) {
attributes['gen_ai.output.messages'] = JSON.stringify(rawOutput);
}
return {
span_id: threadSpanIdAt(index),
trace_id: STORY_TRACE_ID,
parent_span_id: PARENT_SPAN_ID,
name: template.name,
kind_string: 'Internal',
time_unix: Math.round(traceStart + index * 1_200),
duration_nano: 1_113_318_541,
has_error: false,
status_code_string: 'Unset',
status_message: '',
resource: {
'service.name': 'travel-assistant',
'deployment.environment': 'production',
},
attributes,
events: [],
references: [
{ traceId: STORY_TRACE_ID, spanId: PARENT_SPAN_ID, refType: 'CHILD_OF' },
],
formattedInput: template.input ?? [],
formattedOutput: template.output ?? [],
formatterWarnings: [],
};
};
export const threadSpanCount = (scenario: ThreadScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
interface ThreadPageOptions {
scenario: ThreadScenario;
paging: ThreadPaging;
traceStart: number;
after: string | null;
}
/** The first page, or the second when `after` is its cursor. */
export const traceThreadResponse = ({
scenario,
paging,
traceStart,
after,
}: ThreadPageOptions): GetTraceThread200 => {
const spans = SCENARIO_TEMPLATES[scenario].map((template, index) =>
toSpan(template, index, traceStart),
);
const split =
paging === 'single-page' ? spans.length : Math.ceil(spans.length / 2);
const isSecondPage = after === SECOND_PAGE_CURSOR;
return {
status: 'success',
data: isSecondPage
? { spans: spans.slice(split) }
: {
spans: spans.slice(0, split),
nextCursor: split < spans.length ? SECOND_PAGE_CURSOR : undefined,
},
};
};

View File

@@ -0,0 +1,22 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { ThreadMessage } from '../AIThreadMessage/types';
export type ThreadSpan = SpanV3 & {
formattedInput?: ThreadMessage[];
formattedOutput?: ThreadMessage[];
};
export enum ThreadView {
Formatted = 'formatted',
Json = 'json',
}
export enum InterestedSpanStatus {
None = 'none',
Loading = 'loading',
Found = 'found',
// Backend anchors on the span's position but drops it for lacking messages.
NoMessages = 'noMessages',
NotFound = 'notFound',
}

View File

@@ -0,0 +1,46 @@
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { isAxiosError } from 'axios';
import { collectToolCallKeys } from '../AIThreadMessage/utils';
import { InterestedSpanStatus, ThreadSpan } from './types';
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
export function toThreadSpan(span: SpantypesThreadSpanDTO): ThreadSpan {
return {
...(span as unknown as ThreadSpan),
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
};
}
export function isNotFoundError(error: unknown): boolean {
return isAxiosError(error) && error.response?.status === 404;
}
export function buildToolCallIndex(spans: ThreadSpan[]): Set<string> {
return new Set(
spans.flatMap((span) => [
...collectToolCallKeys(span.formattedInput ?? []),
...collectToolCallKeys(span.formattedOutput ?? []),
]),
);
}
export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
}
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (spans.length === 0) {
return InterestedSpanStatus.NotFound;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoMessages;
}

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}

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