Compare commits

..

27 Commits

Author SHA1 Message Date
Gaurav Tewari
a8eae4b633 refactor(trace-details): extract the span details position into a hook 2026-10-08 02:50:52 +05:30
Gaurav Tewari
5663330191 Merge branch 'feat/trace-details-tabs' into feat/trace-details-thread-tab 2026-10-08 00:23:35 +05:30
Gaurav Tewari
349535510a Merge branch 'feat/trace-waterfall-tooltip' into feat/trace-details-tabs 2026-10-08 00:22:24 +05:30
Gaurav Tewari
d5948adc8e Merge branch 'feat/trace-summary-api' into feat/trace-waterfall-tooltip 2026-10-08 00:22:18 +05:30
Gaurav Tewari
fe8aebc15a Merge remote-tracking branch 'refs/remotes/origin/main' into feat/trace-summary-api 2026-10-08 00:21:56 +05:30
Gaurav Tewari
7d78edc708 feat: trace header details thread tabs refactor 2026-10-07 23:54:44 +05:30
Gaurav Tewari
9d1efe75e2 chore: revert trace summary changes 2026-10-07 23:24:10 +05:30
Gaurav Tewari
73bf4bcc76 feat(trace-details): add Overview and Thread tabs 2026-10-07 22:58:45 +05:30
Gaurav Tewari
a8daa1bf18 refactor(trace-details): move the page body into TraceDetailsOverview
Tabs and thread changes move to a follow-up PR.
2026-10-07 22:58:11 +05:30
Gaurav Tewari
b04381a6b0 test(trace-details): adapt zero-token header test to the summary mock 2026-10-07 22:57:18 +05:30
Gaurav Tewari
2c642d6f41 Merge remote-tracking branch 'origin/feat/trace-waterfall-tooltip' into feat/trace-details-tabs 2026-10-07 22:42:54 +05:30
Gaurav Tewari
27a635c40c chore: self review change 2026-10-07 22:41:43 +05:30
Gaurav Tewari
6099faf30c chore: minor css update 2026-10-07 22:02:31 +05:30
Gaurav Tewari
050c295179 feat: minor changes 2026-10-07 21:25:32 +05:30
Gaurav Tewari
e5e0f169ff feat: update css 2026-10-07 20:47:12 +05:30
Gaurav Tewari
1cd716d3ad refactor: self review changes 2026-10-07 19:52:59 +05:30
Gaurav Tewari
78ecfb7bc7 feat: update ai logic. we don't need to calcuate things on frontend 2026-10-07 19:07:56 +05:30
Gaurav Tewari
27ada1241b refactor(trace-details): split the page into Overview and Thread tabs
The header reads the trace summary itself, so Overview no longer feeds it.

Assisted-by: Claude Opus 5.5
2026-10-07 18:32:55 +05:30
Gaurav Tewari
38cf950ce7 test(trace-details): use renamed header options prop in AI usage tests
Assisted-by: Claude Opus 5.5
2026-10-07 18:32:38 +05:30
Gaurav Tewari
e5de83cc31 feat(trace-details): share tooltip styles and show AI tokens and cost in the header
Assisted-by: Claude Opus 5.5
2026-10-07 18:29:58 +05:30
Gaurav Tewari
5b0a409751 feat: update badge color 2026-10-07 18:29:57 +05:30
Gaurav Tewari
bcd222f046 feat: add support for badge and update ai icons 2026-10-07 18:29:57 +05:30
Gaurav Tewari
faa92bb38c chore: update tooltip design and support ai spans 2026-10-07 18:29:57 +05:30
Gaurav Tewari
0ae98b05c5 feat: loading in skeleton 2026-10-07 17:51:36 +05:30
Gaurav Tewari
7c76ba8d9e feat: update header options 2026-10-07 17:35:39 +05:30
Gaurav Tewari
104758f3cf refactor(trace-details): derive header filter range from the trace summary
Assisted-by: Claude Opus 5.5
2026-10-07 17:15:20 +05:30
Gaurav Tewari
504be0dfd7 feat(trace-details): read header metadata from the trace summary API
Assisted-by: Claude Opus 5.5
2026-10-07 16:59:38 +05:30
61 changed files with 1885 additions and 1140 deletions

View File

@@ -10286,9 +10286,6 @@ components:
reasoning:
minimum: 0
type: integer
totalInput:
minimum: 0
type: integer
required:
- input
- output

View File

@@ -11361,11 +11361,6 @@ export interface SpantypesTraceAITokensDTO {
* @minimum 0
*/
reasoning: number;
/**
* @type integer
* @minimum 0
*/
totalInput?: number;
}
export interface SpantypesTraceAISummaryDTO {

View File

@@ -21,6 +21,7 @@ import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -29,7 +30,6 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
@@ -729,71 +729,65 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
};
return (
<TruncatedTooltip
<div
key={option.value || `option-${index}`}
title={String(option.label || '')}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
e.stopPropagation();
e.preventDefault();
handleItemSelection();
}
}}
onMouseEnter={(): void => {
setActiveIndex(index ?? -1);
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
}}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<Checkbox
value={isSelected}
className="option-checkbox"
<Checkbox
value={isSelected}
className="option-checkbox"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<Typography.Text truncate={1} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</Typography.Text>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{(option.type === 'custom' || option.type === 'regex') && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
{option.value && ensureValidOption(option.value) && (
<Button
type="text"
className="only-btn"
onClick={(e): void => selectFromButton(e, 'option')}
>
{currentToggleTagValue({ option: option.value })}
</Button>
)}
<Button
type="text"
className="toggle-btn"
onClick={(e): void => selectFromButton(e, 'checkbox')}
>
Toggle
</Button>
</div>
</Checkbox>
Toggle
</Button>
</div>
)}
</TruncatedTooltip>
</Checkbox>
</div>
);
},
[

View File

@@ -16,7 +16,6 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { Select } from 'antd';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
@@ -26,7 +25,6 @@ import { capitalize, isEmpty } from 'lodash-es';
import type { BaseSelectRef } from 'rc-select';
import { popupContainer } from 'utils/selectPopupContainer';
import TruncatedTooltip from './TruncatedTooltip';
import { CustomSelectProps, OptionData } from './types';
import {
filterOptionsBySearch,
@@ -209,46 +207,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
const optionId = `option-${index}`;
return (
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
{(textRef): JSX.Element => (
<div
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
)}
</TruncatedTooltip>
<div
key={option.value}
id={optionId}
ref={(el): void => {
if (index !== undefined) {
optionRefs.current[index] = el;
}
}}
className={cx('option-item', {
selected: isSelected,
active: isActive,
})}
onClick={(e): void => {
e.stopPropagation();
handleSelection();
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === SPACEKEY) {
e.preventDefault();
handleSelection();
}
}}
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
tabIndex={isActive ? 0 : -1}
>
<div className="option-content">
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
{option.type === 'custom' && (
<div className="option-badge">{capitalize(option.type)}</div>
)}
</div>
</div>
);
},
[highlightMatchedText, searchText, onChange, activeOptionIndex],
@@ -709,67 +702,50 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
// ===== Final Processing =====
// Apply highlight to matched text in options
const optionsWithHighlight = useMemo(() => {
const matching =
options?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
) ?? [];
// antd shows a value with no matching option as a bare string, so keep the
// selected value listed to render it through the tooltip label below.
return (
isEmpty(value)
? matching
: prioritizeOrAddOptionForSingleSelect(matching, value)
).map((option) => ({
...option,
// Rendered as the selected value in the closed control.
label: (
<TruncatedTooltip title={String(option.label || '')} side="bottom">
{(textRef): JSX.Element => (
<span ref={textRef} className="option-label-text">
{highlightMatchedText(String(option.label || ''), searchText)}
</span>
)}
</TruncatedTooltip>
),
}));
}, [options, searchText, value, highlightMatchedText]);
const optionsWithHighlight = useMemo(
() =>
options
?.filter((option) =>
String(option.label || '')
.toLowerCase()
.includes(searchText.toLowerCase()),
)
?.map((option) => ({
...option,
label: highlightMatchedText(String(option.label || ''), searchText),
})),
[options, searchText, highlightMatchedText],
);
// ===== Component Rendering =====
return (
// Self-provided so label tooltips work wherever this select is rendered.
<TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
</TooltipProvider>
<Select
ref={selectRef}
className={cx('custom-select', className)}
placeholder={placeholder}
showSearch
filterOption={false}
onSearch={handleSearch}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
options={optionsWithHighlight}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={popupMatchSelectWidth}
allowClear={allowClear ? { clearIcon } : false}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
listHeight={300}
placement={placement}
optionFilterProp="label"
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
{...rest}
/>
);
};

View File

@@ -1,4 +0,0 @@
// Above the antd select popup it is shown from.
.tooltipContent {
--tooltip-z-index: 2100;
}

View File

@@ -1,41 +0,0 @@
import { ReactElement, RefObject, useRef, useState } from 'react';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import styles from './TruncatedTooltip.module.scss';
interface TruncatedTooltipProps {
title: string;
side?: TooltipSimpleProps['side'];
/** Renders the trigger; attach the ref to the element that may truncate. */
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
}
function TruncatedTooltip({
title,
side = 'right',
children,
}: TruncatedTooltipProps): JSX.Element {
const textRef = useRef<HTMLSpanElement>(null);
const [open, setOpen] = useState(false);
const handleOpenChange = (next: boolean): void => {
const el = textRef.current;
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
};
return (
<TooltipSimple
title={title}
side={side}
arrow
delayDuration={300}
open={open}
onOpenChange={handleOpenChange}
tooltipContentProps={{ className: styles.tooltipContent }}
>
{children(textRef)}
</TooltipSimple>
);
}
export default TruncatedTooltip;

View File

@@ -1,34 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
describe('CustomSelect selected value tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it.each([
['listed in the options', [{ label: LABEL, value: LABEL }]],
['missing from the options', [{ label: 'other', value: 'other' }]],
])('reveals a truncated selected value %s on hover', async (_, options) => {
render(<CustomSelect options={options} value={LABEL} />);
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(screen.getByText(LABEL));
act(() => {
jest.advanceTimersByTime(500);
});
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
});

View File

@@ -1,63 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import TruncatedTooltip from '../TruncatedTooltip';
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
function renderRow(scrollWidth: number, clientWidth: number): void {
jest
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
.mockReturnValue(scrollWidth);
jest
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
.mockReturnValue(clientWidth);
render(
<TooltipProvider>
<TruncatedTooltip title={LABEL}>
{(textRef): JSX.Element => (
<div data-testid="row">
<span ref={textRef}>{LABEL}</span>
</div>
)}
</TruncatedTooltip>
</TooltipProvider>,
);
}
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('TruncatedTooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reveals truncated text when its row is hovered', async () => {
renderRow(400, 200);
await hover(screen.getByTestId('row'));
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
});
it('shows no tooltip when the text fits', async () => {
renderRow(200, 200);
await hover(screen.getByTestId('row'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -135,51 +135,6 @@ const LONG_LABEL_OPTION = {
value: 'checkout-payment-authorisation',
};
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
export const TruncatedOption: Story = {
args: { options: [LONG_LABEL_OPTION, ...options] },
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Service' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: hovering a cut-off selected value reveals it in full. */
export const TruncatedValue: Story = {
args: {
options: [LONG_LABEL_OPTION, ...options],
value: LONG_LABEL_OPTION.value,
},
play: async (): Promise<void> => {
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/** Truncation: a multi-select option reveals its full label the same way. */
export const MultiTruncatedOption: Story = {
render: (): JSX.Element => (
<CustomMultiSelect
aria-label="Services"
options={[LONG_LABEL_OPTION, ...options]}
/>
),
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('combobox', { name: 'Services' }),
);
await userEvent.hover(
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
);
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
},
};
/**
* Every tooltip the select renders, held open: the selected chip revealing the
* option label it was cut from. Nothing bounds that label, so the chip is given

View File

@@ -29,13 +29,6 @@ $custom-border-color: #2c3044;
background-color: var(--l2-background);
color: color-mix(in srgb, var(--border) 70%, transparent);
}
// The search input overlays the selected value; lift the value above it while
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
&:not(.ant-select-open) .ant-select-selection-item {
position: relative;
z-index: 1;
}
}
// Keep chip styles ONLY in the multi-select
@@ -230,33 +223,6 @@ $custom-border-color: #2c3044;
}
}
// Ellipsized option label; also the selected value in the closed single select.
.option-label-text {
display: block;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
.custom-select-dropdown,
.custom-multiselect-dropdown {
position: relative;
overscroll-behavior: contain;
&::after {
content: '';
position: absolute;
top: 100%;
left: 0;
width: 1px;
height: 1px;
}
}
// Custom dropdown styles for single select
.custom-select-dropdown {
padding: 8px 0 0 0;

View File

@@ -5,18 +5,23 @@ import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: ReactNode;
tooltipClassName?: string;
icon?: ReactNode;
children: ReactNode;
}
function EntityMetadataItem({
tooltip,
tooltipClassName,
icon,
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple title={tooltip}>
<TooltipSimple
title={tooltip}
tooltipContentProps={{ className: tooltipClassName }}
>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>
@@ -27,6 +32,7 @@ function EntityMetadataItem({
EntityMetadataItem.defaultProps = {
icon: null,
tooltipClassName: undefined,
};
export default EntityMetadataItem;

View File

@@ -8,3 +8,16 @@
// 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: 160px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}
.tokenTooltipContent {
composes: tooltipContent from '../shared.module.scss';
}

View File

@@ -1,10 +1,13 @@
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 { formatCost, formatTokens } from '../utils/genAi';
import type { SpantypesTraceAITokensDTO } from 'api/generated/services/sigNoz.schemas';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +19,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: SpantypesTraceAITokensDTO;
cost?: number;
}
const ICON_SIZE = 14;
@@ -32,6 +37,8 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const durationTooltip =
@@ -91,6 +98,25 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (tokens.input > 0 || tokens.output > 0) && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
tooltipClassName={styles.tokenTooltipContent}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{cost !== undefined && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +128,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,34 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TraceTooltip/TokenBreakdown';
import type { SpantypesTraceAITokensDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: SpantypesTraceAITokensDTO;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="medium"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -0,0 +1,37 @@
import { CSSProperties } from 'react';
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/genAi';
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 (
<Badge
variant="outline"
// The outline variant derives text, fill and border from this color.
style={{ '--badge-background': color } as CSSProperties}
data-testid={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import SpanHintBadge from '../SpanHintBadge';
const spanWith = (
attributes: Record<string, string | number>,
responseStatusCode = '',
): SpanV3 =>
({
attributes,
resource: {},
response_status_code: responseStatusCode,
}) as unknown as SpanV3;
describe('SpanHintBadge', () => {
it.each([
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
])('shows only the AI badge for %p', (attributes, label) => {
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.queryByText('200')).not.toBeInTheDocument();
});
it('falls back to the HTTP status code for non-AI spans', () => {
render(<SpanHintBadge span={spanWith({}, '503')} />);
expect(screen.getByText('503')).toBeInTheDocument();
});
it('renders nothing for a non-AI span without a status code', () => {
const { container } = render(<SpanHintBadge span={spanWith({})} />);
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,10 @@
import { AiSpanKind } from '../utils/genAi';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: string }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
};

View File

@@ -9,40 +9,24 @@
}
.popover {
composes: tooltipContent from '../shared.module.scss';
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
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);
}
.content {
font-size: 12px;
color: var(--l1-foreground);
composes: tooltipBody from '../shared.module.scss';
width: 223px;
}
.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;
gap: var(--spacing-2);
padding-bottom: var(--spacing-4);
}

View File

@@ -4,6 +4,7 @@ import {
TooltipProvider,
TooltipTrigger,
} 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';
@@ -12,6 +13,11 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -38,6 +44,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -47,33 +54,46 @@ 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} style={{ color }}>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="medium">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TooltipSection>
{ai?.model && <TooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
<TooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TooltipSection>
{previewRows.map((row) => (
<div
<TooltipRow
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>
</TooltipSection>
)}
</div>
);
@@ -137,6 +157,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getSpanAiDetails(span),
},
};
}, [

View File

@@ -0,0 +1,37 @@
import { formatCost } from '../utils/genAi';
import { SpanAiUsage } from './aiUsage';
import TokenBreakdown from '../TraceTooltip/TokenBreakdown';
import TooltipRow from '../TraceTooltip/TooltipRow';
import TooltipSection from '../TraceTooltip/TooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
/>
{cost !== undefined && (
<TooltipSection>
<TooltipRow label="cost" value={formatCost(cost)} />
</TooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,143 @@
import { render, screen } from '@testing-library/react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanAiDetails } from '../aiUsage';
import { SpanTooltipContent } from '../SpanHoverCard';
const baseProps = {
spanName: 'chat gpt-4o',
color: '#fff',
hasError: false,
relativeStartMs: 268,
durationMs: 1110,
};
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getSpanAiDetails', () => {
it('returns undefined for a span without gen_ai attributes', () => {
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
});
it('reads model, tool, agent, token usage and cost', () => {
expect(
getSpanAiDetails(
spanWith({
'gen_ai.request.model': 'gpt-4o',
'gen_ai.tool.name': 'search',
'gen_ai.agent.name': 'planner',
'gen_ai.usage.input_tokens': '120',
'gen_ai.usage.output_tokens': 30,
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
}),
),
).toStrictEqual({
model: 'gpt-4o',
toolName: 'search',
agentName: 'planner',
usage: {
inputTokens: 120,
outputTokens: 30,
cacheReadTokens: undefined,
cacheCreationTokens: undefined,
cost: 0.00000056,
},
});
});
});
describe('SpanTooltipContent', () => {
it('renders status, start and duration for a non-AI span', () => {
render(<SpanTooltipContent {...baseProps} />);
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
expect(screen.getByText('ok')).toBeInTheDocument();
expect(screen.getByText('268 ms')).toBeInTheDocument();
expect(screen.getByText('1.11 s')).toBeInTheDocument();
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
});
it('renders raw input, cache and output tokens with cost', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{
model: 'gpt-4o',
usage: {
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
cost: 0.00000056,
},
}}
/>,
);
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
expect(screen.getByText('Input')).toBeInTheDocument();
expect(screen.getByText('1,000')).toBeInTheDocument();
expect(screen.getByText('Cache')).toBeInTheDocument();
expect(screen.getByText('Cache read')).toBeInTheDocument();
expect(screen.getByText('600')).toBeInTheDocument();
expect(screen.getByText('Cache write')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
expect(screen.getByText('Output')).toBeInTheDocument();
expect(screen.getByText('200')).toBeInTheDocument();
// Raw values only: no derived input or total usage.
expect(screen.queryByText('1,700')).not.toBeInTheDocument();
expect(screen.queryByText('1,200')).not.toBeInTheDocument();
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
});
it('hides a zero cache row, and the cache group when both are zero', () => {
const { rerender } = render(
<SpanTooltipContent
{...baseProps}
ai={{
usage: {
inputTokens: 194,
outputTokens: 32,
cacheReadTokens: 50,
cacheCreationTokens: 0,
},
}}
/>,
);
expect(screen.getByText('Cache read')).toBeInTheDocument();
expect(screen.queryByText('Cache write')).not.toBeInTheDocument();
rerender(
<SpanTooltipContent
{...baseProps}
ai={{
usage: {
inputTokens: 194,
outputTokens: 32,
cacheReadTokens: 0,
cacheCreationTokens: 0,
},
}}
/>,
);
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
expect(screen.queryByText('Cache read')).not.toBeInTheDocument();
});
it('hides cache rows and cost when the attributes are absent', () => {
render(
<SpanTooltipContent
{...baseProps}
ai={{ usage: { inputTokens: 10, outputTokens: 5 } }}
/>,
);
expect(screen.queryByText('Cache')).not.toBeInTheDocument();
expect(screen.queryByText('Cache read')).not.toBeInTheDocument();
expect(screen.queryByText('Cache write')).not.toBeInTheDocument();
expect(screen.queryByText('cost')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,64 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { GEN_AI_KEYS, getGenAiValue } from '../utils/genAi';
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
cost?: number;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getGenAiAttributeAsString(
span: SpanV3,
key: string,
): string | undefined {
const value = getGenAiValue(span, key);
return value === undefined ? undefined : String(value);
}
function getGenAiAttributeAsNumber(
span: SpanV3,
key: string,
): number | undefined {
const value = getGenAiValue(span, key);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.cacheCreationTokens,
),
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
};
const hasUsage =
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
const details: SpanAiDetails = {
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}

View File

@@ -34,6 +34,15 @@
}
}
.tabs {
flex-shrink: 0;
}
.tab {
flex: unset;
padding: 0 24px;
}
.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 {
@@ -18,40 +18,27 @@ 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 { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
showTraceDetailsHeaderOptions?: boolean;
}
const SKELETON_COUNT = 3;
@@ -73,16 +60,23 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
showTraceDetailsHeaderOptions,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
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);
@@ -116,9 +110,13 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
return (
<div className={styles.wrapper}>
@@ -140,9 +138,10 @@ function TraceDetailsHeader({
badgeValue={traceID || ''}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{isDataLoaded && (
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -171,9 +170,9 @@ function TraceDetailsHeader({
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
startTime={startTime}
endTime={endTime}
totalSpansCount={traceSummary.totalSpansCount}
/>
</div>
</TooltipProvider>
@@ -183,9 +182,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
startTime={startTime}
endTime={endTime}
traceID={traceID || ''}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -196,28 +195,32 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceMetadata ? (
{!traceSummary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceMetadata.rootSpanStatusCode}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -0,0 +1,44 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
// Clicking the active item deselects it and reports ''.
if (value) {
void setTab(value as TraceDetailsTab);
}
};
return (
<ToggleGroup
type="single"
value={tab}
onChange={handleChange}
className={styles.tabs}
size="sm"
>
<ToggleGroupItem
value={TraceDetailsTab.Overview}
className={styles.tab}
data-testid="trace-details-tab-overview"
>
Overview
</ToggleGroupItem>
<ToggleGroupItem
value={TraceDetailsTab.Thread}
className={styles.tab}
data-testid="trace-details-tab-thread"
>
Thread
</ToggleGroupItem>
</ToggleGroup>
);
}
export default TraceDetailsTabs;

View File

@@ -1,10 +1,44 @@
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';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
const MOCK_TRACE_SUMMARY: SpantypesGettableTraceSummaryDTO = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000,
rootServiceName: '',
rootServiceEntryPoint: 'Missing Span',
rootSpanStatusCode: '404',
totalSpansCount: 3,
totalErrorSpansCount: 0,
hasMissingSpans: true,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 8000,
cacheWrite: 1200,
reasoning: 900,
},
totalCost: 0.0421,
},
};
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(),
}));
beforeEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: MOCK_TRACE_SUMMARY, isLoading: false });
});
const mockGoBack = jest.fn();
const mockPush = jest.fn();
@@ -51,15 +85,12 @@ jest.mock('components/FieldsSelector', () => ({
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
isDataLoaded: false,
showTraceDetailsHeaderOptions: true,
};
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – back button', () => {
beforeEach(() => {
mockGoBack.mockClear();
@@ -94,8 +125,10 @@ describe('TraceDetailsHeader – action cluster', () => {
mockReplace.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
it('does not render the action buttons until the trace data has loaded', () => {
render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -105,8 +138,8 @@ describe('TraceDetailsHeader – action cluster', () => {
).not.toBeInTheDocument();
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
it('renders Analytics and Settings action buttons', () => {
render(<TraceDetailsHeader {...baseProps} />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -117,7 +150,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
render(<TraceDetailsHeader {...baseProps} />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -133,68 +166,171 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// Plain prop, no API mock needed: traceMetadata is passed straight in.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
rootServiceName: 'inventory-frontend',
rootServiceEntryPoint: 'large-trace-root',
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
};
it('shows skeletons instead of the metadata while the summary loads', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: true });
const { container } = render(<TraceDetailsHeader {...baseProps} />);
it('renders the metadata (service, entry point, duration, status) when provided', () => {
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
// Duration goes through the shared formatter (e.g. "2 min").
it('renders the summary metadata with tokens and cost', () => {
render(<TraceDetailsHeader {...baseProps} />);
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
const duration = getYAxisFormattedValue(
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
`${endTimestampMillis - startTimestampMillis}`,
'ms',
);
expect(screen.getByText(metadataText)).toBeInTheDocument();
expect(screen.getByText(duration)).toBeInTheDocument();
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('renders the root span status code', () => {
render(<TraceDetailsHeader {...baseProps} />);
expect(screen.getByText('404')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
jest.mocked(useTraceSummary).mockReturnValue({
data: { ...MOCK_TRACE_SUMMARY, ai: undefined },
isLoading: false,
});
render(<TraceDetailsHeader {...baseProps} />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('omits AI tokens when input and output are both zero', () => {
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...MOCK_TRACE_SUMMARY,
ai: {
totalCost: 0.0421,
tokens: {
input: 0,
output: 0,
cacheRead: 8000,
cacheWrite: 1200,
reasoning: 900,
},
},
},
isLoading: false,
});
render(<TraceDetailsHeader {...baseProps} />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows skeletons instead of the metadata when the summary is absent', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
const { container } = render(<TraceDetailsHeader {...baseProps} />);
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
render(<TraceDetailsHeader {...baseProps} />);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
// Hide it.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
// Show it again.
await user.click(screen.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /show trace details/i }),
);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
});
describe('TraceDetailsHeader – tabs', () => {
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /trace options/i }),
).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.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /trace options/i }),
).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.getByRole('button', { name: /trace options/i }));
await user.click(
await screen.findByRole('menuitem', { name: /hide trace details/i }),
);
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.getByRole('button', { name: /^analytics$/i }));
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

@@ -0,0 +1,16 @@
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
interface UseTraceSummaryResult {
data: SpantypesGettableTraceSummaryDTO | undefined;
isLoading: boolean;
}
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
const { data, isLoading } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId } },
);
return { data: data?.data, isLoading };
}

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,493 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { 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 { 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 { 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;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
onHasTraceDataChange,
}: 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 || '');
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef('');
const colorByField = useTraceStore((s) => s.colorByField);
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
}, [urlQuery, safeNavigate]);
const 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,
]);
// 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 = 480;
const RIGHT_DOCK_MAX = 720;
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const hasTraceData = !showNoData && allSpans.length > 0;
useEffect(() => {
onHasTraceDataChange(hasTraceData);
return (): void => onHasTraceDataChange(false);
}, [hasTraceData, onHasTraceDataChange]);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
return (
<>
{showNoData ? (
<NoData />
) : (
<>
<div className={styles.layoutRow}>
<div className={styles.content}>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
key: 'flame',
label: (
<div className={styles.collapseLabel}>
<span className={styles.collapseTitle}>
Flame Graph
{traceData?.payload?.totalSpansCount &&
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
<span className={styles.collapseCount}>
<span className={styles.collapseCountItem}>
<ChartNoAxesGantt size={13} />
Spans: {traceData.payload.totalSpansCount}
</span>
<span
className={cx(styles.collapseCountItem, {
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
})}
>
<TriangleAlert size={13} />
Errors:{' '}
{traceData.payload.totalErrorSpansCount ?? (
<span className="translate-safe">{0}</span>
)}
</span>
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
selectedSpan={selectedSpan}
totalSpansCount={totalSpansCount}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className={styles.dockedSpanDetails}>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{showRightDock && (
<ResizableBox
handle="left"
defaultWidth={rightDockWidth}
minWidth={RIGHT_DOCK_MIN}
maxWidth={RIGHT_DOCK_MAX}
onResize={setRightDockWidth}
className={styles.rightDock}
>
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED_RIGHT}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</ResizableBox>
)}
</div>
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
)}
</>
)}
</>
);
}
export default TraceDetailsOverview;

View File

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

View File

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

View File

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

View File

@@ -30,13 +30,9 @@
// lives here. Shares visual treatment with the waterfall hover popover
// but is positioned `fixed` instead of anchored.
.tooltip {
composes: tooltipSurface from '../shared.module.scss';
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,59 @@
import { formatTokens } from '../utils/genAi';
import TooltipRow from './TooltipRow';
import TooltipSection from './TooltipSection';
interface TokenBreakdownProps {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
}
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
}: TokenBreakdownProps): JSX.Element {
const showCacheRead = !!cacheRead;
const showCacheWrite = !!cacheWrite;
return (
<>
{input !== undefined && (
<TooltipSection>
<TooltipRow label="Input" value={formatTokens(input)} isHeading />
</TooltipSection>
)}
{(showCacheRead || showCacheWrite) && (
<TooltipSection>
<TooltipRow label="Cache" isHeading />
{showCacheRead && (
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} isNested />
)}
{showCacheWrite && (
<TooltipRow
label="Cache write"
value={formatTokens(cacheWrite)}
isNested
/>
)}
</TooltipSection>
)}
{output !== undefined && (
<TooltipSection>
<TooltipRow label="Output" value={formatTokens(output)} isHeading />
</TooltipSection>
)}
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
};
export default TokenBreakdown;

View File

@@ -0,0 +1,42 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TraceTooltip.module.scss';
interface TooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
truncate={1}
>
{label}
</Typography.Text>
{value !== undefined && (
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
{value}
</Typography.Text>
)}
</div>
);
}
export default TooltipRow;

View File

@@ -0,0 +1,13 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TooltipSectionProps {
children: ReactNode;
}
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TooltipSection;

View File

@@ -0,0 +1,41 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
&:first-child {
padding-top: 0;
border-top: none;
}
&:last-child {
padding-bottom: 0;
}
}
.row {
display: flex;
justify-content: space-between;
gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowValue {
flex-shrink: 0;
max-width: 60%;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -217,8 +217,9 @@
.statusCell {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -408,6 +409,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -25,7 +25,6 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
@@ -33,7 +32,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/genAi';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
<SpanHintBadge span={span} />
</div>
);
})}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
/* Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
tooltip so they look the same. */
/* Plain CSS only (no SCSS syntax): other modules `composes` from this file. */
/* For plain elements, e.g. the flamegraph's cursor-following portal. */
.tooltipSurface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
/* For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
from these variables. */
.tooltipContent {
--tooltip-background: color-mix(
in srgb,
var(--l3-background) 60%,
transparent
);
--tooltip-border-color: var(--l2-border);
--tooltip-border-radius: var(--radius-2);
--tooltip-padding: var(--spacing-4);
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
--typography-font-family: var(--code-small-500-font-family);
--typography-font-size: var(--code-small-500-font-size);
--typography-font-weight: var(--code-small-500-font-weight);
--typography-line-height: var(--code-small-500-line-height);
letter-spacing: var(--code-small-500-letter-spacing);
word-break: break-all;
}

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ import {
traceDetailFieldKeys,
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -149,6 +150,13 @@ export const traceDetailsMocks = defineStoryMocks({
),
),
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
response.json(() =>
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
),
),
rest.post(
'http://localhost/api/v3/traces/:traceId/flamegraph',
response.json(() => traceFlamegraphResponse(trace)),

View File

@@ -6,6 +6,7 @@
import type {
GetFlamegraph200,
GetTraceAggregations200,
GetTraceSummary200,
GetWaterfallV4200,
SpantypesFlamegraphSpanDTO,
SpantypesSpanAggregationDTO,
@@ -303,6 +304,27 @@ export const traceWaterfallResponse = (
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
const spans = buildSpans(options);
const root = spans[0];
return {
status: 'success',
data: {
startTimestampMillis: Math.round(options.traceStart),
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
rootServiceName: root?.template.service ?? '',
rootServiceEntryPoint: root?.template.name ?? '',
rootSpanStatusCode: root?.hasError ? '503' : '200',
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
},
};
};
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
spanId: span.spanId,
parentSpanId: span.parentSpanId,

View File

@@ -0,0 +1,32 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { AiSpanKind, getAiSpanKind, isAiSpan } from '../genAi';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});

View File

@@ -0,0 +1,67 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export const GEN_AI_KEYS = {
requestModel: 'gen_ai.request.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
export function getGenAiValue(
span: SpanV3,
key: string,
): string | number | boolean | undefined {
const value = span.attributes?.[key] ?? span.resource?.[key];
return value === '' ? undefined : value;
}
function hasAttribute(span: SpanV3, key: string): boolean {
return getGenAiValue(span, key) !== undefined;
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}
export function formatTokens(value: number): string {
return value.toLocaleString('en-US');
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}

View File

@@ -138,7 +138,6 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
"if(countIf(total_input_tokens_value IS NULL AND coalesce(input_tokens_value, 0) + coalesce(cache_read_tokens_value, 0) + coalesce(cache_write_tokens_value, 0) > 0) = 0, toUInt64(sum(total_input_tokens_value)), NULL) AS total_input_tokens",
"sum(total_cost_value) AS total_cost",
)
sb.From(sb.BuilderAs(spans, "spans"))
@@ -149,7 +148,7 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
&stats.Tokens.TotalInput, &stats.TotalCost,
&stats.TotalCost,
)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
@@ -169,7 +168,6 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
{aiobservabilitytypes.SignozGenAITotalInputTokens, "total_input_tokens_value"},
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
}

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,6 @@ var (
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.SignozGenAITotalInputTokens: genAIAttribute(aiobservabilitytypes.SignozGenAITotalInputTokens, telemetrytypes.FieldDataTypeNumber),
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),

View File

@@ -21,15 +21,14 @@ const (
GenAIOutputMessages = "gen_ai.output.messages"
)
// Per-span costs and token totals the SigNoz LLM pricing processor attaches;
// SigNoz semconv (signoz-semantic-conventions model/genai/registry.yaml), not OTel semconv.
// Per-span costs the SigNoz LLM pricing processor attaches; SigNoz semconv
// (signoz-semantic-conventions model/genai/registry.yaml), not OTel semconv.
const (
SignozGenAICostInput = "signoz.gen_ai.usage.input_tokens.cost"
SignozGenAICostOutput = "signoz.gen_ai.usage.output_tokens.cost"
SignozGenAICostCacheRead = "signoz.gen_ai.usage.cache_read.input_tokens.cost"
SignozGenAICostCacheWrite = "signoz.gen_ai.usage.cache_write.input_tokens.cost"
SignozGenAITotalCost = "signoz.gen_ai.usage.tokens.cost"
SignozGenAITotalInputTokens = "signoz.gen_ai.usage.total_input_tokens"
SignozGenAICostInput = "signoz.gen_ai.usage.input_tokens.cost"
SignozGenAICostOutput = "signoz.gen_ai.usage.output_tokens.cost"
SignozGenAICostCacheRead = "signoz.gen_ai.usage.cache_read.input_tokens.cost"
SignozGenAICostCacheWrite = "signoz.gen_ai.usage.cache_write.input_tokens.cost"
SignozGenAITotalCost = "signoz.gen_ai.usage.tokens.cost"
)
// GenAISpanGateKeys mark a span as gen_ai: an LLM call, a tool call, or an

View File

@@ -48,14 +48,13 @@ type LLMPricingRuleProcessorCache struct {
Write float64 `yaml:"write" json:"write"`
}
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost and token fields to span attribute names.
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost fields to span attribute names.
type LLMPricingRuleProcessorOutputAttrs struct {
In string `yaml:"in" json:"in"`
Out string `yaml:"out" json:"out"`
CacheRead string `yaml:"cache_read" json:"cache_read"`
CacheWrite string `yaml:"cache_write" json:"cache_write"`
Total string `yaml:"total" json:"total"`
TotalInputTokens string `yaml:"total_input_tokens" json:"total_input_tokens"`
In string `yaml:"in" json:"in"`
Out string `yaml:"out" json:"out"`
CacheRead string `yaml:"cache_read" json:"cache_read"`
CacheWrite string `yaml:"cache_write" json:"cache_write"`
Total string `yaml:"total" json:"total"`
}
// buildProcessorConfig converts pricing rules into the signozllmpricing processor config.
@@ -104,8 +103,6 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
CacheRead: aiobservabilitytypes.SignozGenAICostCacheRead,
CacheWrite: aiobservabilitytypes.SignozGenAICostCacheWrite,
Total: aiobservabilitytypes.SignozGenAITotalCost,
TotalInputTokens: aiobservabilitytypes.SignozGenAITotalInputTokens,
},
}
}

View File

@@ -18,7 +18,6 @@ processors:
cache_read: signoz.gen_ai.usage.cache_read.input_tokens.cost
cache_write: signoz.gen_ai.usage.cache_write.input_tokens.cost
total: signoz.gen_ai.usage.tokens.cost
total_input_tokens: signoz.gen_ai.usage.total_input_tokens
receivers:
otlp:
protocols:

View File

@@ -26,7 +26,6 @@ processors:
cache_read: signoz.gen_ai.usage.cache_read.input_tokens.cost
cache_write: signoz.gen_ai.usage.cache_write.input_tokens.cost
total: signoz.gen_ai.usage.tokens.cost
total_input_tokens: signoz.gen_ai.usage.total_input_tokens
receivers:
otlp:
protocols:

View File

@@ -23,7 +23,6 @@ processors:
cache_read: signoz.gen_ai.usage.cache_read.input_tokens.cost
cache_write: signoz.gen_ai.usage.cache_write.input_tokens.cost
total: signoz.gen_ai.usage.tokens.cost
total_input_tokens: signoz.gen_ai.usage.total_input_tokens
receivers:
otlp:
protocols:

View File

@@ -27,7 +27,6 @@ processors:
cache_read: signoz.gen_ai.usage.cache_read.input_tokens.cost
cache_write: signoz.gen_ai.usage.cache_write.input_tokens.cost
total: signoz.gen_ai.usage.tokens.cost
total_input_tokens: signoz.gen_ai.usage.total_input_tokens
receivers:
otlp:
protocols:

View File

@@ -42,9 +42,6 @@ type TraceAITokens struct {
CacheRead uint64 `json:"cacheRead" required:"true"`
CacheWrite uint64 `json:"cacheWrite" required:"true"`
Reasoning uint64 `json:"reasoning" required:"true"`
// TotalInput is nil, not 0, when any span with input tokens lacks the key,
// since a partial sum misleads and 0 is a valid total.
TotalInput *uint64 `json:"totalInput,omitempty" nullable:"false"`
}
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {

View File

@@ -32,7 +32,6 @@ def test_summary_ai_trace(
) -> None:
"""The summary carries the waterfall's trace-level fields and, for a trace with gen_ai
spans, token totals over every LLM span and the cost summed over the spans that carry it.
The total input count is dropped because one LLM span was never priced.
Spans are written to one layout only, so a read from the wrong column sums to zero."""
use_attribute_backend(attribute_backend)
write_mode = "json_only" if attribute_backend == "json" else "legacy_only"
@@ -60,7 +59,6 @@ def test_summary_ai_trace(
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 20,
"gen_ai.usage.cache_read.input_tokens": 7,
"signoz.gen_ai.usage.total_input_tokens": 100,
"signoz.gen_ai.usage.tokens.cost": 0.01,
},
attribute_write_mode=write_mode,
@@ -170,13 +168,7 @@ def test_summary_ai_trace_across_json_rollout(
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 20,
"signoz.gen_ai.usage.total_input_tokens": 100,
"signoz.gen_ai.usage.tokens.cost": 0.01,
},
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20, "signoz.gen_ai.usage.tokens.cost": 0.01},
attribute_write_mode="legacy_only",
),
Traces(
@@ -189,13 +181,7 @@ def test_summary_ai_trace_across_json_rollout(
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.usage.input_tokens": 50,
"gen_ai.usage.output_tokens": 5,
"signoz.gen_ai.usage.total_input_tokens": 50,
"signoz.gen_ai.usage.tokens.cost": 0.02,
},
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.input_tokens": 50, "gen_ai.usage.output_tokens": 5, "signoz.gen_ai.usage.tokens.cost": 0.02},
attribute_write_mode="json_only",
),
]
@@ -213,7 +199,7 @@ def test_summary_ai_trace_across_json_rollout(
assert summary["totalSpansCount"] == 3
assert summary["rootServiceEntryPoint"] == "long agent run"
assert summary["rootSpanStatusCode"] == "200"
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalInput": 150}
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0}
assert summary["ai"]["totalCost"] == pytest.approx(0.03)