mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-09 03:30:55 +01:00
Compare commits
2 Commits
feat/trace
...
issue_nerv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38edbfbb00 | ||
|
|
c407456be7 |
@@ -10286,6 +10286,9 @@ components:
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalInput:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- input
|
||||
- output
|
||||
|
||||
@@ -11361,6 +11361,11 @@ export interface SpantypesTraceAITokensDTO {
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalInput?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
|
||||
@@ -21,7 +21,6 @@ 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';
|
||||
@@ -30,6 +29,7 @@ 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,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<TruncatedTooltip
|
||||
key={option.value || `option-${index}`}
|
||||
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}
|
||||
title={String(option.label || '')}
|
||||
>
|
||||
<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"
|
||||
{(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"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -25,6 +26,7 @@ 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,
|
||||
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
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],
|
||||
);
|
||||
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]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,51 @@ 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
|
||||
|
||||
@@ -29,6 +29,13 @@ $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
|
||||
@@ -223,6 +230,33 @@ $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;
|
||||
|
||||
@@ -138,6 +138,7 @@ 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"))
|
||||
@@ -148,7 +149,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.TotalCost,
|
||||
&stats.Tokens.TotalInput, &stats.TotalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
|
||||
@@ -168,6 +169,7 @@ 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
@@ -21,6 +21,7 @@ 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),
|
||||
|
||||
@@ -21,14 +21,15 @@ const (
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
)
|
||||
|
||||
// Per-span costs the SigNoz LLM pricing processor attaches; SigNoz semconv
|
||||
// (signoz-semantic-conventions model/genai/registry.yaml), not OTel semconv.
|
||||
// Per-span costs and token totals 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"
|
||||
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"
|
||||
)
|
||||
|
||||
// GenAISpanGateKeys mark a span as gen_ai: an LLM call, a tool call, or an
|
||||
|
||||
@@ -48,13 +48,14 @@ type LLMPricingRuleProcessorCache struct {
|
||||
Write float64 `yaml:"write" json:"write"`
|
||||
}
|
||||
|
||||
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost fields to span attribute names.
|
||||
// LLMPricingRuleProcessorOutputAttrs maps the processor's computed cost and token 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// buildProcessorConfig converts pricing rules into the signozllmpricing processor config.
|
||||
@@ -103,6 +104,8 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
CacheRead: aiobservabilitytypes.SignozGenAICostCacheRead,
|
||||
CacheWrite: aiobservabilitytypes.SignozGenAICostCacheWrite,
|
||||
Total: aiobservabilitytypes.SignozGenAITotalCost,
|
||||
|
||||
TotalInputTokens: aiobservabilitytypes.SignozGenAITotalInputTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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:
|
||||
|
||||
@@ -26,6 +26,7 @@ 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:
|
||||
|
||||
@@ -23,6 +23,7 @@ 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:
|
||||
|
||||
@@ -27,6 +27,7 @@ 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:
|
||||
|
||||
@@ -42,6 +42,9 @@ 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 {
|
||||
|
||||
@@ -32,6 +32,7 @@ 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"
|
||||
@@ -59,6 +60,7 @@ 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,
|
||||
@@ -168,7 +170,13 @@ 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.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.total_input_tokens": 100,
|
||||
"signoz.gen_ai.usage.tokens.cost": 0.01,
|
||||
},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
@@ -181,7 +189,13 @@ 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.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.total_input_tokens": 50,
|
||||
"signoz.gen_ai.usage.tokens.cost": 0.02,
|
||||
},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
@@ -199,7 +213,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}
|
||||
assert summary["ai"]["tokens"] == {"input": 150, "output": 25, "cacheRead": 0, "cacheWrite": 0, "reasoning": 0, "totalInput": 150}
|
||||
assert summary["ai"]["totalCost"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user