Compare commits

..

4 Commits

Author SHA1 Message Date
Gaurav Tewari
3db7e2cee6 feat(trace-details): add the AI thread view 2026-10-12 16:26:24 +05:30
Gaurav Tewari
f79212ad07 feat(trace-details): add Overview and Thread tabs 2026-10-12 16:15:58 +05:30
Gaurav Tewari
fc3fbb2bc1 refactor(trace-details): move the page body into TraceDetailsOverview 2026-10-12 13:43:18 +05:30
Gaurav Tewari
5fe47d23a5 feat(trace-details): waterfall tooltip redesign and AI span badges 2026-10-12 13:30:07 +05:30
112 changed files with 4367 additions and 1459 deletions

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 60 60"><linearGradient id="a" x1="15.959" x2="44.307" y1="-13.014" y2="15.333" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".097" style="stop-color:#0095d5"/><stop offset=".301" style="stop-color:#238ad9"/><stop offset=".621" style="stop-color:#557bde"/><stop offset=".864" style="stop-color:#7472e2"/><stop offset="1" style="stop-color:#806ee3"/></linearGradient><path d="m0 60 30.1-30.1L60 60z" style="fill:url(#a)"/><linearGradient id="b" x1="4.209" x2="20.673" y1="48.941" y2="65.405" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".118" style="stop-color:#0095d5"/><stop offset=".418" style="stop-color:#3c83dc"/><stop offset=".696" style="stop-color:#6d74e1"/><stop offset=".833" style="stop-color:#806ee3"/></linearGradient><path d="M0 0h30.1L0 32.5z" style="fill:url(#b)"/><linearGradient id="c" x1="-10.102" x2="45.731" y1="5.836" y2="61.669" gradientTransform="matrix(1 0 0 -1 0 61)" gradientUnits="userSpaceOnUse"><stop offset=".107" style="stop-color:#c757bc"/><stop offset=".214" style="stop-color:#d0609a"/><stop offset=".425" style="stop-color:#e1725c"/><stop offset=".605" style="stop-color:#ee7e2f"/><stop offset=".743" style="stop-color:#f58613"/><stop offset=".823" style="stop-color:#f88909"/></linearGradient><path d="M30.1 0 0 31.7V60l30.1-30.1L60 0z" style="fill:url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#9CA3AF" d="M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#F25022" d="M1 1h10.5v10.5H1z"/><path fill="#7FBA00" d="M12.5 1H23v10.5H12.5z"/><path fill="#00A4EF" d="M1 12.5h10.5V23H1z"/><path fill="#FFB900" d="M12.5 12.5H23V23H12.5z"/></svg>

Before

Width:  |  Height:  |  Size: 264 B

View File

@@ -6,6 +6,7 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -33,8 +34,6 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
@@ -59,16 +58,12 @@ function ListView({
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const {
selectedTime: globalSelectedTime,
@@ -97,6 +92,7 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
orderBy,
],
[
stagedQuery,
@@ -105,6 +101,7 @@ function ListView({
paginationConfig,
maxTime,
minTime,
orderBy,
],
);
@@ -170,12 +167,9 @@ function ListView({
[queryTableData],
);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -6,6 +6,7 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -22,7 +23,6 @@ import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
@@ -57,13 +57,9 @@ function TracesView({
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const { stagedQuery, panelType } = useQueryBuilder();
const orderBy = useMemo(
() => getListOrderBy(stagedQuery, TRACE_VIEW_DEFAULT_ORDER_BY),
[stagedQuery],
);
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const {
columns,
@@ -98,6 +94,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -106,6 +103,7 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -167,12 +165,9 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(

View File

@@ -1,7 +1,6 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -12,7 +11,12 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [parseListOrderBy(orderBy)]
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -16,14 +16,14 @@ function LogsActionsContainer({
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
onOrderChange,
setOrderBy,
explorerActions,
}: {
listQuery: any;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
onOrderChange: (value: string) => void;
setOrderBy: (value: string) => void;
explorerActions: ReactNode;
}): JSX.Element {
const { options, config } = useOptionsMenu({
@@ -78,7 +78,7 @@ function LogsActionsContainer({
<ListViewOrderBy
value={orderBy}
onChange={onOrderChange}
onChange={(value): void => setOrderBy(value)}
dataSource={DataSource.LOGS}
/>
</div>

View File

@@ -59,7 +59,6 @@ import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import LogsActionsContainer from './LogsActionsContainer';
@@ -102,8 +101,7 @@ function LogsExplorerViewsContainer({
const currentMinTimeRef = useRef<number>(minTime);
// Context
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const { stagedQuery, panelType } = useQueryBuilder();
const selectedPanelType = panelType || PANEL_TYPES.LIST;
@@ -113,7 +111,7 @@ function LogsExplorerViewsContainer({
const [requestData, setRequestData] = useState<Query | null>(null);
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
@@ -194,13 +192,6 @@ function LogsExplorerViewsContainer({
'custom',
);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const getRequestData = useCallback(
(
query: Query | null,
@@ -332,10 +323,18 @@ function LogsExplorerViewsContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
// Store previous orderDirection to detect changes
const prevOrderByRef = useRef(orderBy);
useEffect(() => {
const orderByChanged =
prevOrderByRef.current !== orderBy && selectedPanelType === PANEL_TYPES.LIST;
prevOrderByRef.current = orderBy;
if (
requestData?.id !== stagedQuery?.id ||
currentMinTimeRef.current !== minTime
currentMinTimeRef.current !== minTime ||
orderByChanged
) {
const newRequestData = getRequestData(stagedQuery, {
filters: listQuery?.filters || initialFilters,
@@ -359,6 +358,7 @@ function LogsExplorerViewsContainer({
activeLogId,
selectedPanelType,
maxTime,
orderBy,
]);
const chartData = useMemo(() => {
@@ -430,7 +430,7 @@ function LogsExplorerViewsContainer({
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
onOrderChange={handleOrderChange}
setOrderBy={setOrderBy}
explorerActions={explorerActions}
/>
)}

View File

@@ -1,7 +1,8 @@
import agnoUrl from '@/assets/Logos/agno.svg';
import amazonBedrockUrl from '@/assets/Logos/amazon-bedrock.svg';
import amazonMskUrl from '@/assets/Logos/amazon-msk.svg';
import androidUrl from '@/assets/Logos/android.svg';
import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg';
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
import antigravityUrl from '@/assets/Logos/antigravity.svg';
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
@@ -24,7 +25,6 @@ import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metric
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import azureUrl from '@/assets/Logos/azure.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import bunUrl from '@/assets/Logos/bun.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
@@ -126,7 +126,6 @@ import logstashUrl from '@/assets/Logos/logstash.svg';
import mastraUrl from '@/assets/Logos/mastra.svg';
import metaUrl from '@/assets/Logos/meta.svg';
import metricsUrl from '@/assets/Logos/metrics.svg';
import microsoftUrl from '@/assets/Logos/microsoft.svg';
import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
import mistralUrl from '@/assets/Logos/mistral.svg';
import modalUrl from '@/assets/Logos/modal.svg';
@@ -4713,40 +4712,70 @@ const onboardingConfigWithLinks = [
link: '/docs/messaging-queues/celery-setup/',
},
{
dataSource: 'android',
label: 'Android',
imgUrl: androidUrl,
dataSource: 'android-java',
label: 'Android Java',
imgUrl: androidJavaMonitoringUrl,
tags: ['mobile app monitoring'],
module: 'apm',
relatedSearchKeywords: [
'android',
'android app tracing',
'android java',
'android kotlin',
'android java instrumentation',
'android java monitoring',
'android java to signoz',
'android logs',
'android metrics',
'android observability',
'android performance monitoring',
'android to signoz',
'android-java',
'apm',
'app',
'application performance monitoring',
'java',
'kotlin',
'logs',
'metrics',
'mobile',
'mobile app monitoring',
'monitor android apps',
'monitoring',
'observability',
'opentelemetry android',
'otel android agent',
'opentelemetry android java',
'otel android sdk',
'traces',
'tracing',
],
id: 'android',
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-android/',
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-java/',
},
{
dataSource: 'android-kotlin',
label: 'Android Kotlin',
imgUrl: androidKotlinMonitoringUrl,
tags: ['mobile app monitoring'],
module: 'apm',
relatedSearchKeywords: [
'android',
'android app performance kotlin',
'android kotlin',
'android kotlin monitoring',
'android kotlin to signoz',
'android observability kotlin',
'android telemetry sdk',
'android-kotlin',
'apm',
'app',
'application performance monitoring',
'kotlin',
'kotlin app logs',
'kotlin mobile tracing',
'mobile',
'mobile app monitoring',
'monitor kotlin apps',
'monitoring',
'observability',
'opentelemetry android kotlin',
'otel kotlin sdk',
'traces',
'tracing',
],
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-kotlin/',
},
{
dataSource: 'flutter',
@@ -7958,112 +7987,5 @@ const onboardingConfigWithLinks = [
id: 'aws-lambda-microvms',
link: '/docs/aws-monitoring/lambda-microvms/',
},
{
dataSource: 'bun-elysia',
label: 'Bun / Elysia',
imgUrl: bunUrl,
tags: ['apm/traces', 'metrics'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'application performance monitoring',
'bun',
'bun elysia',
'bun metrics',
'bun monitoring',
'bun observability',
'bun traces',
'bunjs',
'elysia',
'elysiajs',
'javascript',
'metrics',
'observability',
'opentelemetry bun',
'traces',
'tracing',
'typescript',
],
id: 'bun-elysia',
link: '/docs/instrumentation/opentelemetry-bun/',
},
{
dataSource: 'snmp',
label: 'SNMP',
imgUrl: metricsUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'firewall',
'metrics',
'monitoring',
'network',
'network devices',
'network monitoring',
'observability',
'opentelemetry snmp',
'router',
'snmp',
'snmp metrics',
'snmp receiver',
'switch',
],
id: 'snmp',
link: '/docs/metrics-management/opentelemetry-snmp-metrics/',
},
{
dataSource: 'vercel-eve',
label: 'Vercel Eve',
imgUrl: vercelUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'agent',
'agents',
'ai agent',
'eve',
'eve dev',
'eve observability',
'llm',
'llm monitoring',
'monitoring',
'observability',
'token usage',
'traces',
'tracing',
'vercel',
'vercel agents',
'vercel eve',
],
id: 'vercel-eve',
link: '/docs/eve-observability/',
},
{
dataSource: 'microsoft-agent-framework',
label: 'Microsoft Agent Framework',
imgUrl: microsoftUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'agent',
'agent framework',
'agents',
'ai agent',
'llm',
'llm monitoring',
'maf',
'microsoft',
'microsoft agent framework',
'microsoft agent framework observability',
'monitoring',
'observability',
'token usage',
'traces',
'tracing',
'workflows',
],
id: 'microsoft-agent-framework',
link: '/docs/microsoft-agent-framework-observability/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -7,6 +7,7 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
@@ -33,7 +34,6 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -66,16 +66,12 @@ function ListView({
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const {
selectedTime: globalSelectedTime,
@@ -127,6 +123,7 @@ function ListView({
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
@@ -136,6 +133,7 @@ function ListView({
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
@@ -217,12 +215,9 @@ function ListView({
[config],
);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -2,7 +2,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -13,7 +12,12 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [parseListOrderBy(orderBy)]
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -1,108 +0,0 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('hooks/saveViews/useGetSavedViewParams', () => ({
useGetSavedViewParams: (): { viewName: string; viewKey: string } => ({
viewName: '',
viewKey: '',
}),
}));
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
const countOrderBy = [{ columnName: 'count()', order: 'desc' }];
const queryWithOrder = (orderBy: typeof countOrderBy): Query => ({
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((item) => ({
...item,
orderBy,
})),
},
});
const setUpQueryBuilder = (
from: PANEL_TYPES,
currentQuery: Query,
): jest.Mock => {
const redirectWithQueryBuilderData = jest.fn();
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType: from,
redirectWithQueryBuilderData,
updateAllQueriesOperators: (query: Query): Query => query,
updateQueriesData: (
query: Query,
type: 'queryData',
updateCallback: (item: IBuilderQuery, index: number) => IBuilderQuery,
): Query => ({
...query,
builder: {
...query.builder,
[type]: query.builder[type].map(updateCallback),
},
}),
} as unknown as ReturnType<typeof useQueryBuilder>);
return redirectWithQueryBuilderData;
};
const switchTab = (from: PANEL_TYPES, to: PANEL_TYPES, query: Query): Query => {
const redirect = setUpQueryBuilder(from, query);
const { result } = renderHook(() => useHandleExplorerTabChange());
result.current.handleExplorerTabChange(to);
return redirect.mock.calls[0][0] as Query;
};
describe('explorer tab change clears the order for raw panels', () => {
it.each([
[PANEL_TYPES.TIME_SERIES, PANEL_TYPES.LIST],
[PANEL_TYPES.TABLE, PANEL_TYPES.LIST],
[PANEL_TYPES.TRACE, PANEL_TYPES.LIST],
[PANEL_TYPES.LIST, PANEL_TYPES.TRACE],
])('clears it on %s -> %s', (from, to) => {
const query = switchTab(from, to, queryWithOrder(countOrderBy));
query.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([]);
});
});
it.each([
[PANEL_TYPES.LIST, PANEL_TYPES.TIME_SERIES],
[PANEL_TYPES.LIST, PANEL_TYPES.TABLE],
])('keeps it on %s -> %s, where the sanitizer validates it', (from, to) => {
const query = switchTab(from, to, queryWithOrder(countOrderBy));
query.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual(countOrderBy);
});
});
it('leaves the order alone when the caller supplies its own query', () => {
const saved = queryWithOrder([{ columnName: 'timestamp', order: 'asc' }]);
const redirect = setUpQueryBuilder(
PANEL_TYPES.TIME_SERIES,
queryWithOrder(countOrderBy),
);
const { result } = renderHook(() => useHandleExplorerTabChange());
result.current.handleExplorerTabChange(PANEL_TYPES.LIST, { query: saved });
const sent = redirect.mock.calls[0][0] as Query;
sent.builder.queryData.forEach((item) => {
expect(item.orderBy).toStrictEqual([
{ columnName: 'timestamp', order: 'asc' },
]);
});
});
});

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import { initialAutocompleteData, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { SIGNOZ_VALUE } from 'container/QueryBuilder/filters/OrderByFilter/constants';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -46,7 +47,7 @@ export const useHandleExplorerTabChange = (): {
) {
query = updateQueriesData(query, 'queryData', (item) => ({
...item,
orderBy: [],
orderBy: item.orderBy.filter((item) => item.columnName !== SIGNOZ_VALUE),
aggregateAttribute: initialAutocompleteData,
}));
}

View File

@@ -0,0 +1,247 @@
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.parts {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.empty {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.textBox {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-3) var(--spacing-5);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
min-width: 0;
}
.text {
font-size: var(--periscope-font-size-base);
line-height: var(--line-height-20);
color: var(--l1-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(ol),
:global(ul) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
}
.plainText {
white-space: pre-wrap;
}
.showMore {
align-self: flex-start;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--primary);
cursor: pointer;
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
max-width: 100%;
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
background: var(--l2-background);
font-size: var(--font-size-xs);
color: var(--l1-foreground);
min-width: 0;
}
.dot {
flex-shrink: 0;
width: var(--spacing-3);
height: var(--spacing-3);
border-radius: var(--radius-round);
&[data-tone='success'] {
background: var(--accent-forest);
}
&[data-tone='warning'] {
background: var(--accent-amber);
}
&[data-tone='error'] {
background: var(--accent-cherry);
}
}
.chipLabel {
flex-shrink: 0;
}
.chipSeparator {
flex-shrink: 0;
color: var(--l2-foreground);
}
.chipValue {
display: inline-flex;
gap: var(--spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--accent-primary);
}
.chipKey {
flex-shrink: 0;
}
.toolName {
overflow: hidden;
text-overflow: ellipsis;
color: var(--accent-amber);
}
.toolCall,
.toolResult {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.toolCall {
border-radius: var(--radius-2);
transition: outline-color 0.3s ease;
outline: 1px solid transparent;
outline-offset: var(--spacing-1);
&.isHighlighted {
outline-color: var(--primary);
}
}
.toolLink {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 0;
border: none;
background: none;
color: var(--l2-foreground);
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.collapsible {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
&.boxed {
gap: 0;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
.collapsibleToggle {
justify-content: space-between;
padding: var(--spacing-3) var(--spacing-5);
}
.collapsibleBody {
padding: 0 var(--spacing-5) var(--spacing-4);
}
}
}
.collapsibleToggle {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
padding: 0;
border: none;
background: none;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
cursor: pointer;
text-align: left;
}
.collapsibleLabel {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.collapsibleBody {
display: flex;
flex-direction: column;
min-width: 0;
}
.json {
min-width: 0;
padding: var(--spacing-3) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
cursor: auto;
:global(.pretty-view) {
padding: 0;
}
}
.mediaChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
align-self: flex-start;
padding: var(--spacing-2) var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-round);
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,89 @@
import { Badge } from '@signozhq/ui/badge';
import { ThreadMessage } from '../TraceDetailsThread/types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
isOutput?: boolean;
isToolLinkEnabled?: (toolCallId: string) => boolean;
onToolLinkClick?: (toolCallId: string) => void;
}
function AIThreadMessage({
message,
isOutput,
isToolLinkEnabled,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, content, finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const parts = (
<div className={styles.parts}>
{content.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
content.map((part, index) => (
<MessagePart
// Parts have no stable id and never reorder.
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' ? (
<CollapsibleBlock label={roleLabel} testId="ai-message-system-toggle">
{parts}
</CollapsibleBlock>
) : (
<>
{roleLabel && (
<div className={styles.roleHeader}>
{roleLabel}
{isOutput && (
<Badge color={tone} testId="ai-message-output-badge">
Output
</Badge>
)}
</div>
)}
{parts}
</>
)}
{finishReason && (
<PartChip
label="Finish Reason"
value={finishReason}
tone={tone}
testId="ai-message-finish-reason"
/>
)}
</div>
);
}
AIThreadMessage.defaultProps = {
isOutput: false,
isToolLinkEnabled: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -0,0 +1,50 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
interface CollapsibleBlockProps {
label: ReactNode;
// Boxed: full-width bordered row with the chevron on the right.
isBoxed?: boolean;
testId?: string;
children: ReactNode;
}
function CollapsibleBlock({
label,
isBoxed = false,
testId,
children,
}: CollapsibleBlockProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsOpen((prev) => !prev);
};
return (
<div className={cx(styles.collapsible, isBoxed && styles.boxed)}>
<button
type="button"
className={styles.collapsibleToggle}
onClick={handleToggle}
aria-expanded={isOpen}
data-testid={testId}
>
<span className={styles.collapsibleLabel}>{label}</span>
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{isOpen && <div className={styles.collapsibleBody}>{children}</div>}
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

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

View File

@@ -0,0 +1,26 @@
import { PrettyView } from 'periscope/components/PrettyView';
import styles from './AIThreadMessage.module.scss';
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.json}
onClick={(event): void => event.stopPropagation()}
>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
/>
</div>
);
}
export default JsonBlock;

View File

@@ -0,0 +1,64 @@
import { MessagePart as MessagePartType } from '../TraceDetailsThread/types';
import MediaPart from './parts/MediaPart';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
interface MessagePartProps {
part: MessagePartType;
isToolLinkEnabled?: (toolCallId: string) => boolean;
onToolLinkClick?: (toolCallId: string) => void;
}
function MessagePart({
part,
isToolLinkEnabled,
onToolLinkClick,
}: MessagePartProps): JSX.Element | null {
switch (part.type) {
case 'text':
return <TextContent text={part.content} isMarkdown />;
case 'thinking':
return <ThinkingPart content={part.content} />;
case 'tool_call':
return (
<ToolCallPart
id={part.id}
name={part.name}
args={part.arguments}
server={part.server}
/>
);
case 'tool_result':
return (
<ToolResultPart
toolCallId={part.toolCallId}
name={part.name}
content={part.content}
isError={part.isError}
isLinkEnabled={!!isToolLinkEnabled?.(part.toolCallId)}
onLinkClick={onToolLinkClick}
/>
);
case 'media':
return (
<MediaPart
modality={part.modality}
mimeType={part.mimeType}
name={part.name}
/>
);
case 'generic':
return <TextContent text={part.content} />;
default:
return null;
}
}
MessagePart.defaultProps = {
isToolLinkEnabled: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -0,0 +1,44 @@
import { ReactNode } from 'react';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span className={styles.chip} data-testid={testId}>
<span className={styles.dot} data-tone={tone} />
<span className={styles.chipLabel}>{label}</span>
{value !== undefined && (
<>
<span className={styles.chipSeparator}>—</span>
<span className={styles.chipValue}>{value}</span>
</>
)}
{children}
</span>
);
}
PartChip.defaultProps = {
value: undefined,
tone: 'success',
testId: undefined,
children: undefined,
};
export default PartChip;

View File

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

View File

@@ -0,0 +1,28 @@
import { Image } from '@signozhq/icons';
import styles from '../AIThreadMessage.module.scss';
interface MediaPartProps {
modality?: string;
mimeType?: string;
name?: string;
}
function MediaPart({ modality, mimeType, name }: MediaPartProps): JSX.Element {
return (
<span className={styles.mediaChip} data-testid="ai-media-part">
<Image size={12} />
<span className="translate-safe">
{name || mimeType || modality || 'Media'}
</span>
</span>
);
}
MediaPart.defaultProps = {
modality: undefined,
mimeType: undefined,
name: undefined,
};
export default MediaPart;

View File

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

View File

@@ -0,0 +1,69 @@
import { useMemo } from 'react';
import { Badge } from '@signozhq/ui/badge';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
import TextContent from '../TextContent';
import { parseJsonObject, TOOL_CALL_ID_ATTR } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolCallPartProps {
id: string;
name: string;
args: unknown;
server?: boolean;
}
function ToolCallPart({
id,
name,
args,
server,
}: ToolCallPartProps): JSX.Element {
// Streamed calls can carry truncated, unparseable argument strings.
const json = useMemo(() => {
if (typeof args === 'string') {
return parseJsonObject(args);
}
return typeof args === 'object' && args !== null ? args : undefined;
}, [args]);
const toolCallAttr = { [TOOL_CALL_ID_ATTR]: id };
return (
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
<PartChip
label="tool_call"
value={
<>
<span className={styles.chipKey}>name :</span>
<span className={styles.toolName}>{name}</span>
</>
}
>
{server && (
<Badge color="secondary" variant="outline">
server
</Badge>
)}
</PartChip>
{json ? (
<JsonBlock data={json} />
) : (
args !== undefined &&
args !== null && (
<TextContent
text={typeof args === 'string' ? args : JSON.stringify(args)}
/>
)
)}
</div>
);
}
ToolCallPart.defaultProps = {
server: false,
};
export default ToolCallPart;

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
import { ThreadMessage } from '../TraceDetailsThread/types';
export const MAX_TEXT_LENGTH = 1000;
export const MAX_INLINE_RESULT_LENGTH = 80;
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
const ROLE_LABELS: Record<string, string> = {
system: 'System',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error';
const FINISH_REASON_TONES: Record<string, MessageTone> = {
length: 'warning',
content_filter: 'warning',
error: 'error',
};
/** Undefined for missing or unknown roles, which render headerless. */
export function getRoleLabel(role?: string): string | undefined {
return role ? ROLE_LABELS[role] : undefined;
}
export function getFinishReasonTone(reason?: string): MessageTone {
return (reason && FINISH_REASON_TONES[reason]) || 'success';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const trimmed = text.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(trimmed);
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
} catch {
return undefined;
}
}
export function collectToolCallIds(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.content.flatMap((part) =>
part.type === 'tool_call' ? [part.id] : [],
),
);
}

View File

@@ -7,3 +7,9 @@
cursor: default;
color: inherit;
}
// Above the floating span details panel (z-index 999).
.tooltip {
composes: tooltipContent from '../shared.module.scss';
--tooltip-z-index: 1000;
}

View File

@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: TooltipSimpleProps['title'];
icon?: ReactNode;
children: ReactNode;
}
@@ -16,7 +16,10 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<TooltipSimple title={tooltip}>
<TooltipSimple
title={tooltip}
tooltipContentProps={{ className: styles.tooltip }}
>
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>

View File

@@ -8,3 +8,12 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,10 +1,18 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,8 +42,11 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -91,6 +104,24 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,35 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -0,0 +1,25 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4) 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: var(--letter-spacing-wide);
}
.scroll {
max-height: 480px;
overflow-y: auto;
padding: var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}

View File

@@ -0,0 +1,62 @@
import { useMemo, useRef, useState } from 'react';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import SpanMessages from '../../SpanMessages/SpanMessages';
import {
getSpanMessages,
hasAnyMessages,
hasFormattedMessages,
} from '../../SpanMessages/utils';
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import styles from './SpanAIMessages.module.scss';
interface SpanAIMessagesProps {
span: ThreadSpan;
}
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
const data = useMemo(() => getSpanMessages(span), [span]);
const canFormat = hasFormattedMessages(data);
const [view, setView] = useState(ThreadView.Formatted);
const effectiveView = canFormat ? view : ThreadView.Json;
const scrollRef = useRef<HTMLDivElement>(null);
const toolCallIds = useMemo(() => buildToolCallIndex([span]), [span]);
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
scrollRef,
toolCallIds,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} data-testid="span-ai-messages">
<div className={styles.header}>
Messages
{canFormat && (
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId="span-ai-messages-view-toggle"
/>
)}
</div>
<div className={styles.scroll} ref={scrollRef}>
<SpanMessages
span={span}
view={effectiveView}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
</div>
</div>
);
}
export default SpanAIMessages;

View File

@@ -52,6 +52,7 @@ import Events from './Events/Events';
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
import SpanLogs from './SpanLogs/SpanLogs';
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
import SpanSummary from './SpanSummary';
import styles from './SpanDetailsPanel.module.scss';
@@ -320,6 +321,7 @@ function SpanDetailsContent({
<div className={styles.tabsScroll}>
<TabsContent value="overview">
{isWide && summary}
<SpanAIMessages span={selectedSpan} />
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"

View File

@@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -31,6 +33,10 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -63,6 +69,8 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import { 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/aiTraceUtils';
import { AI_SPAN_BADGES } from './constants';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<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,10 @@
import { AiSpanKind } from '../utils/aiTraceUtils';
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

@@ -8,16 +8,12 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,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';
min-width: 223px;
max-width: 360px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
.title {
composes: tooltipTitle from '../shared.module.scss';
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
.header {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2);
}

View File

@@ -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 { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -38,6 +44,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -47,33 +54,54 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
<div className={styles.header}>
<Typography.Text
className={styles.title}
size="small"
weight="normal"
style={{ color }}
>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="normal">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TraceTooltipSection>
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TraceTooltipRow
label="start"
value={`${toFixed(relativeStartMs, 2)} ms`}
/>
<TraceTooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TraceTooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TraceTooltipSection>
{previewRows.map((row) => (
<div
<TraceTooltipRow
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
))}
</div>
</TraceTooltipSection>
)}
</div>
);
@@ -137,6 +165,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -0,0 +1,38 @@
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
reasoning={reasoningTokens}
/>
{hasValue(cost) && (
<TraceTooltipSection>
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
</TraceTooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import {
MessagePart,
ThreadMessage,
ThreadSpan,
} from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from '../utils/aiTraceUtils';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function toFallbackPart(part: unknown): MessagePart {
if (typeof part === 'string') {
return { type: 'text', content: part };
}
if (isRecord(part) && part.type === 'text') {
const text = part.text ?? part.content;
if (typeof text === 'string') {
return { type: 'text', content: text };
}
}
return { type: 'generic', content: JSON.stringify(part, null, 2) };
}
function toFallbackMessage(item: unknown): ThreadMessage {
if (!isRecord(item)) {
return { content: [toFallbackPart(item)] };
}
const body = item.parts ?? item.content;
return {
role: typeof item.role === 'string' ? item.role : undefined,
content: Array.isArray(body)
? body.map(toFallbackPart)
: [toFallbackPart(body ?? item)],
};
}
/** Best-effort messages from a raw `gen_ai.*.messages` value. */
function toFallbackMessages(raw?: RawMessagesValue): ThreadMessage[] {
if (raw === undefined) {
return [];
}
return Array.isArray(raw)
? raw.map(toFallbackMessage)
: [toFallbackMessage(raw)];
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// TODO: drop the raw-key fallback once the thread API sends formatted_input/output (#13003).
return {
formattedInput: span.formatted_input?.length
? span.formatted_input
: toFallbackMessages(rawInput),
formattedOutput: span.formatted_output?.length
? span.formatted_output
: toFallbackMessages(rawOutput),
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || !!data.rawInput || !!data.rawOutput;
}

View File

@@ -0,0 +1,81 @@
import {
AiTokenCounts,
formatTokens,
getTokenLabel,
getTotalInputTokens,
} from '../utils/aiTraceUtils';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
type TokenBreakdownProps = AiTokenCounts;
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
reasoning,
}: TokenBreakdownProps): JSX.Element {
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
return (
<>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Total Input', totalInput)}
value={formatTokens(totalInput)}
isHeading
/>
<TraceTooltipRow
label={getTokenLabel('Cache Read', cacheRead)}
value={formatTokens(cacheRead)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Cache Write', cacheWrite)}
value={formatTokens(cacheWrite)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Input', input)}
value={formatTokens(input)}
isNested
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Reasoning', reasoning)}
value={formatTokens(reasoning)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Output', output)}
value={formatTokens(output)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label="Total Token Usage"
value={formatTokens(totalInput + (output ?? 0))}
isHeading
/>
</TraceTooltipSection>
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
reasoning: undefined,
};
export default TokenBreakdown;

View File

@@ -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,22 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { DataSource } from 'types/common/queryBuilder';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { useTraceStore } from '../stores/traceStore';
import TraceDownloadPanel from './TraceDownloadPanel';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import TraceDetailsTabs from './TraceDetailsTabs';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface TraceDetailsHeaderProps {
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
showTraceDetailsHeaderOptions?: boolean;
}
@@ -65,6 +69,14 @@ function TraceDetailsHeader({
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const [tab] = useTraceDetailsTab();
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
useEffect(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
@@ -98,13 +110,14 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
useTraceDetailsStripInfo({
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
});
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
: 0;
return (
<div className={styles.wrapper}>
<div className={styles.header}>
@@ -125,9 +138,10 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -181,28 +195,32 @@ function TraceDetailsHeader({
)}
</div>
{showTraceDetails && (
{(showTraceDetails || !isOverview) && (
<div className={styles.subHeader}>
{traceSummary ? (
{!traceSummary ? (
<DetailsLoader />
) : (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
}}
durationMs={durationMs}
durationMs={
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />
)}
</div>
)}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -0,0 +1,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,6 +1,8 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -176,9 +178,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: typeof traceMetadata): void => {
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
@@ -201,6 +213,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(duration)).toBeInTheDocument();
});
it('renders AI tokens and cost when the summary has them', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
mockSummary({ ...traceMetadata, ai: undefined });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('shows AI tokens when input and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows zero for a missing input', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: undefined },
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
});
it('shows zero AI tokens when input and output are both missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: {
...traceMetadata.ai.tokens,
input: undefined,
output: undefined,
},
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
});
it('omits the cost when the summary sends null', () => {
mockSummary({
...traceMetadata,
ai: { ...traceMetadata.ai, totalCost: null },
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);
@@ -233,3 +316,96 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});
const metadataText = /Missing Span/;
describe('TraceDetailsHeader – tabs', () => {
beforeEach(() => {
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
rootServiceEntryPoint: 'Missing Span',
hasMissingSpans: true,
},
isLoading: false,
});
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
// In-memory URL state, so a tab switch can't leak into the next test.
const renderHeader = (): void => {
render(
<NuqsTestingAdapter hasMemory>
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
</NuqsTestingAdapter>,
);
};
it('shows the Overview-only sections on the Overview tab', () => {
renderHeader();
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
expect(
screen.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

@@ -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,495 @@
/* 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 { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import NoData from '../NoData/NoData';
import {
setTraceStoreAvailableColorByFields,
useTraceStore,
} from '../stores/traceStore';
import { 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 => {
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
safeNavigate({ search: params.toString() }, { replace: true });
}, [safeNavigate]);
const panelState = useDetailsPanel({
entityId: selectedSpanId,
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
const deepLinkResolvedRef = useRef(false);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
const frozenParamsRef = useRef({
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
});
const queryParams = fullDataLoadedRef.current
? frozenParamsRef.current
: {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
});
const allSpans = traceData?.payload?.spans || [];
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
frozenParamsRef.current = {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
useEffect(() => {
if (
!traceId ||
dataLoadedFiredForRef.current === traceId ||
!userPrefsReady
) {
return;
}
const payload = traceData?.payload;
if (!payload?.spans?.length) {
return;
}
dataLoadedFiredForRef.current = traceId;
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
[TraceDetailEventKeys.NumServices]: numServices,
[TraceDetailEventKeys.TraceDurationMs]:
payload.endTimestampMillis - payload.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
[TraceDetailEventKeys.FlamegraphSampled]:
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
[TraceDetailEventKeys.SpanPanelVariant]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
});
}, [
traceId,
userPrefsReady,
traceData,
totalSpansCount,
colorByField,
previewFieldsCount,
logTraceEvent,
]);
// 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,32 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-4);
padding: var(--spacing-16);
text-align: center;
}
.icon {
color: var(--l2-foreground);
}
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
display: flex;
gap: var(--spacing-4);
margin-top: var(--spacing-4);
}

View File

@@ -0,0 +1,34 @@
import { ReactNode } from 'react';
import styles from './ThreadEmptyState.module.scss';
interface ThreadEmptyStateProps {
icon: ReactNode;
title: string;
description: string;
actions?: ReactNode;
testId: string;
}
function ThreadEmptyState({
icon,
title,
description,
actions,
testId,
}: ThreadEmptyStateProps): JSX.Element {
return (
<div className={styles.root} data-testid={testId}>
<span className={styles.icon}>{icon}</span>
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
{actions && <div className={styles.actions}>{actions}</div>}
</div>
);
}
ThreadEmptyState.defaultProps = {
actions: undefined,
};
export default ThreadEmptyState;

View File

@@ -0,0 +1,40 @@
.root {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: 428px;
}
.emoji {
font-size: 32px;
line-height: 1;
}
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.text {
margin: 0;
font-family: var(--paragraph-base-500-font-family);
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: var(--paragraph-base-500-letter-spacing);
color: var(--l2-foreground);
}
.highlight {
composes: text;
color: var(--l1-foreground);
}

View File

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

View File

@@ -0,0 +1,17 @@
import { Skeleton } from '@signozhq/ui/skeleton';
import styles from './ThreadSpanList.module.scss';
interface ThreadPageLoaderProps {
testId: string;
}
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
return (
<div className={styles.pageLoader} data-testid={testId}>
<Skeleton active title={{ width: '30%' }} paragraph={{ rows: 2 }} />
</div>
);
}
export default ThreadPageLoader;

View File

@@ -0,0 +1,16 @@
.card {
padding: var(--spacing-8);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
min-width: 0;
cursor: pointer;
&:hover {
border-color: var(--l3-border);
}
&.isSelected {
border-color: var(--primary);
}
}

View File

@@ -0,0 +1,44 @@
import { memo } from 'react';
import cx from 'classnames';
import SpanMessages from '../SpanMessages/SpanMessages';
import { ThreadSpan, ThreadView } from './types';
import styles from './ThreadSpanCard.module.scss';
interface ThreadSpanCardProps {
span: ThreadSpan;
view: ThreadView;
isSelected: boolean;
onSelect: (span: ThreadSpan) => void;
isToolLinkEnabled: (toolCallId: string) => boolean;
onToolLinkClick: (toolCallId: string) => void;
}
function ThreadSpanCard({
span,
view,
isSelected,
onSelect,
isToolLinkEnabled,
onToolLinkClick,
}: ThreadSpanCardProps): JSX.Element {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
<article
className={cx(styles.card, isSelected && styles.isSelected)}
onClick={(): void => onSelect(span)}
data-span-id={span.span_id}
data-testid="thread-span-card"
>
<SpanMessages
span={span}
view={view}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
</article>
);
}
export default memo(ThreadSpanCard);

View File

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

View File

@@ -0,0 +1,23 @@
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-6) var(--spacing-8);
// Prepend compensation is manual; native anchoring would double-shift.
overflow-anchor: none;
}
.sentinel {
flex-shrink: 0;
height: 1px;
}
.pageLoader {
flex-shrink: 0;
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
}

View File

@@ -0,0 +1,55 @@
import { ReactNode, RefObject } from 'react';
import { useThreadInfiniteScroll } from './hooks/useThreadInfiniteScroll';
import ThreadPageLoader from './ThreadPageLoader';
import styles from './ThreadSpanList.module.scss';
interface ThreadSpanListProps {
listRef: RefObject<HTMLDivElement>;
firstItemKey?: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
children: ReactNode;
}
function ThreadSpanList({
listRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
children,
}: ThreadSpanListProps): JSX.Element {
const { topSentinelRef, bottomSentinelRef } = useThreadInfiniteScroll({
containerRef: listRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
});
return (
<div className={styles.list} ref={listRef} data-testid="thread-span-list">
<div ref={topSentinelRef} className={styles.sentinel} />
{isFetchingPreviousPage && (
<ThreadPageLoader testId="thread-loading-previous" />
)}
{children}
{isFetchingNextPage && <ThreadPageLoader testId="thread-loading-next" />}
<div ref={bottomSentinelRef} className={styles.sentinel} />
</div>
);
}
export default ThreadSpanList;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,152 @@
import { useMemo, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Callout } from '@signozhq/ui/callout';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
import { TraceDetailsTab } from '../constants';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useScrollToAnchorSpan } from './hooks/useScrollToAnchorSpan';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
import ThreadNoAiSpans from './ThreadNoAiSpans';
import ThreadPageLoader from './ThreadPageLoader';
import ThreadSpanCard from './ThreadSpanCard';
import ThreadSpanDetails from './ThreadSpanDetails';
import ThreadSpanList from './ThreadSpanList';
import ThreadSpanNotFound from './ThreadSpanNotFound';
import ThreadViewToggle from './ThreadViewToggle';
import { AnchorStatus } from './types';
import { buildToolCallIndex, getAnchorStatus } from './utils';
import styles from './TraceDetailsThread.module.scss';
const INITIAL_LOADER_COUNT = 3;
function TraceDetailsThread(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const [, setTab] = useTraceDetailsTab();
const [view, setView] = useThreadView();
const { anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState } =
useThreadSpanSelection();
const thread = useTraceThread(traceId || '', anchorSpanId);
const { spans, isLoading, isError } = thread;
const listRef = useRef<HTMLDivElement>(null);
const toolCallIds = useMemo(() => buildToolCallIndex(spans), [spans]);
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
listRef,
toolCallIds,
);
const anchorStatus = getAnchorStatus(anchorSpanId, spans, isLoading);
useScrollToAnchorSpan(
listRef,
anchorSpanId,
anchorStatus === AnchorStatus.Found,
);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const spanDetails = panelState.isOpen && selectedSpan && (
<ThreadSpanDetails
panelState={panelState}
span={selectedSpan}
variant={spanDetailVariant}
onVariantChange={setSpanDetailVariant}
/>
);
const hasNoThread =
!isLoading &&
anchorStatus !== AnchorStatus.NotFound &&
(isError || spans.length === 0);
const renderBody = (): JSX.Element => {
if (isLoading) {
return (
<div className={styles.initialLoader} data-testid="thread-loading">
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<ThreadPageLoader key={i} testId="thread-loading-item" />
))}
</div>
);
}
if (anchorStatus === AnchorStatus.NotFound) {
return (
<ThreadSpanNotFound
onGoBack={(): void => {
void setTab(TraceDetailsTab.Overview);
}}
onShowThread={clearAnchor}
/>
);
}
if (hasNoThread) {
return <ThreadNoAiSpans />;
}
return (
<>
{anchorStatus === AnchorStatus.NoMessages && (
<Callout
type="info"
size="small"
className={styles.notice}
title="The requested span has no input or output messages. Showing the rest of the thread."
testId="thread-anchor-no-messages"
/>
)}
<ThreadSpanList
listRef={listRef}
firstItemKey={spans[0]?.span_id}
hasNextPage={thread.hasNextPage}
hasPreviousPage={thread.hasPreviousPage}
isFetchingNextPage={thread.isFetchingNextPage}
isFetchingPreviousPage={thread.isFetchingPreviousPage}
fetchNextPage={thread.fetchNextPage}
fetchPreviousPage={thread.fetchPreviousPage}
>
{spans.map((span) => (
<ThreadSpanCard
key={span.span_id}
span={span}
view={view}
isSelected={span.span_id === selectedSpanId}
onSelect={selectSpan}
isToolLinkEnabled={isToolLinkEnabled}
onToolLinkClick={onToolLinkClick}
/>
))}
</ThreadSpanList>
</>
);
};
return (
<div className={styles.root} data-testid="trace-details-thread">
<div className={styles.content}>
{!hasNoThread && (
<div className={styles.toolbar}>
<ThreadViewToggle
value={view}
onChange={(next): void => {
void setView(next);
}}
/>
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
</div>
)}
{renderBody()}
{isBottomDocked && spanDetails}
</div>
{!isBottomDocked && spanDetails}
</div>
);
}
export default TraceDetailsThread;

View File

@@ -0,0 +1,28 @@
import { RefObject, useEffect } from 'react';
const ROOT_MARGIN = '200px 0px';
/** Calls `onVisible` while enabled and the sentinel is within reach of the container's viewport. */
export function useIntersectionSentinel(
containerRef: RefObject<HTMLElement>,
sentinelRef: RefObject<HTMLElement>,
isEnabled: boolean,
onVisible: () => void,
): void {
useEffect(() => {
const sentinel = sentinelRef.current;
if (!isEnabled || !sentinel) {
return undefined;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
onVisible();
}
},
{ root: containerRef.current, rootMargin: ROOT_MARGIN },
);
observer.observe(sentinel);
return (): void => observer.disconnect();
}, [containerRef, sentinelRef, isEnabled, onVisible]);
}

View File

@@ -0,0 +1,27 @@
import { RefObject, useEffect, useRef } from 'react';
/** Scrolls the anchor span's card into view once, after it first renders. */
export function useScrollToAnchorSpan(
containerRef: RefObject<HTMLElement>,
anchorSpanId: string | undefined,
isAnchorLoaded: boolean,
): void {
const scrolledForRef = useRef<string>();
useEffect(() => {
if (
!anchorSpanId ||
!isAnchorLoaded ||
scrolledForRef.current === anchorSpanId
) {
return;
}
const card = containerRef.current?.querySelector<HTMLElement>(
`[data-span-id="${CSS.escape(anchorSpanId)}"]`,
);
if (card) {
scrolledForRef.current = anchorSpanId;
card.scrollIntoView({ block: 'start' });
}
}, [containerRef, anchorSpanId, isAnchorLoaded]);
}

View File

@@ -0,0 +1,70 @@
import { RefObject, useLayoutEffect, useRef } from 'react';
import { useIntersectionSentinel } from './useIntersectionSentinel';
interface UseThreadInfiniteScrollOptions {
containerRef: RefObject<HTMLDivElement>;
firstItemKey?: string;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
interface UseThreadInfiniteScrollResult {
topSentinelRef: RefObject<HTMLDivElement>;
bottomSentinelRef: RefObject<HTMLDivElement>;
}
/** Loads pages as either list edge nears view, holding position on prepend. */
export function useThreadInfiniteScroll({
containerRef,
firstItemKey,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
}: UseThreadInfiniteScrollOptions): UseThreadInfiniteScrollResult {
const topSentinelRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const heightBeforePrependRef = useRef<number | null>(null);
const loadPreviousRef = useRef((): void => {});
loadPreviousRef.current = (): void => {
heightBeforePrependRef.current = containerRef.current?.scrollHeight ?? null;
void fetchPreviousPage();
};
// Stable wrapper so the observer isn't recreated on every render.
const stableLoadPrevious = useRef((): void =>
loadPreviousRef.current(),
).current;
useIntersectionSentinel(
containerRef,
bottomSentinelRef,
hasNextPage && !isFetchingNextPage,
fetchNextPage,
);
useIntersectionSentinel(
containerRef,
topSentinelRef,
hasPreviousPage && !isFetchingPreviousPage,
stableLoadPrevious,
);
useLayoutEffect(() => {
const container = containerRef.current;
const previousHeight = heightBeforePrependRef.current;
if (!container || previousHeight === null) {
return;
}
heightBeforePrependRef.current = null;
container.scrollTop += container.scrollHeight - previousHeight;
}, [containerRef, firstItemKey]);
return { topSentinelRef, bottomSentinelRef };
}

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { useInfiniteQuery } from 'react-query';
import {
getGetTraceThreadQueryKey,
getTraceThread,
} from 'api/generated/services/tracedetail';
import type { GetTraceThreadParams } from 'api/generated/services/sigNoz.schemas';
import { ThreadSpan } from '../types';
import { isNotFoundError, toThreadSpan } from '../utils';
const THREAD_PAGE_LIMIT = 100;
const MAX_RETRIES = 3;
type PageParam = Pick<GetTraceThreadParams, 'after' | 'before'>;
interface UseTraceThreadResult {
spans: ThreadSpan[];
isLoading: boolean;
isError: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
export function useTraceThread(
traceId: string,
anchorSpanId?: string,
): UseTraceThreadResult {
const query = useInfiniteQuery({
queryKey: getGetTraceThreadQueryKey(
{ traceID: traceId },
{ spanId: anchorSpanId },
),
queryFn: ({ pageParam, signal }) => {
const { after, before } = (pageParam ?? {}) as PageParam;
return getTraceThread(
{ traceID: traceId },
{
limit: THREAD_PAGE_LIMIT,
after,
before,
// The anchor only shapes the first page; cursors drive the rest.
spanId: after || before ? undefined : anchorSpanId,
},
signal,
);
},
getNextPageParam: (lastPage): PageParam | undefined =>
lastPage.data.nextCursor ? { after: lastPage.data.nextCursor } : undefined,
getPreviousPageParam: (firstPage): PageParam | undefined =>
firstPage.data.prevCursor
? { before: firstPage.data.prevCursor }
: undefined,
// An unknown anchor span is a 404; retrying won't find it.
retry: (failureCount, error): boolean =>
!isNotFoundError(error) && failureCount < MAX_RETRIES,
enabled: !!traceId,
refetchOnWindowFocus: false,
});
const spans = useMemo(
() =>
query.data?.pages.flatMap((page) => page.data.spans.map(toThreadSpan)) ?? [],
[query.data],
);
return {
spans,
isLoading: query.isLoading,
isError: query.isError,
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,
isFetchingPreviousPage: query.isFetchingPreviousPage,
fetchNextPage: query.fetchNextPage,
fetchPreviousPage: query.fetchPreviousPage,
};
}

View File

@@ -0,0 +1,55 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export type MessagePart =
| { type: 'text'; content: string }
| { type: 'thinking'; content?: string }
| {
type: 'tool_call';
id: string;
name: string;
arguments: unknown;
server?: boolean;
}
| {
type: 'tool_result';
toolCallId: string;
name?: string;
content: string;
isError?: boolean;
}
| {
type: 'media';
modality?: string;
mimeType?: string;
uri?: string;
name?: string;
sizeBytes?: number;
}
| { type: 'generic'; content: string };
export interface ThreadMessage {
// system | user | assistant | tool; any other value renders headerless.
role?: string;
content: MessagePart[];
// stop | tool_call | length | content_filter | error, or provider-specific.
finishReason?: string;
}
export type ThreadSpan = SpanV3 & {
formatted_input?: ThreadMessage[];
formatted_output?: ThreadMessage[];
};
export enum ThreadView {
Formatted = 'formatted',
Json = 'json',
}
export enum AnchorStatus {
None = 'none',
Loading = 'loading',
Found = 'found',
// Backend anchors on the span's position but drops it for lacking messages.
NoMessages = 'noMessages',
NotFound = 'notFound',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
}
.section:first-child {
padding-top: 0;
border-top: none;
}
.section:last-child {
padding-bottom: 0;
}
.row {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
column-gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.rowValue {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
@@ -18,6 +18,7 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -179,12 +180,11 @@ function Filters({
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
const searchParams = getUnstableCurrentSearchParams();
if (spanId) {
searchParams.set('spanId', spanId);
} else {
@@ -193,7 +193,7 @@ function Filters({
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
[filteredSpanIds, history],
);
const { isFetching, error } = useGetQueryRange(

View File

@@ -30,12 +30,8 @@
// loads cross-module composes through the plain CSS parser, which chokes on
// SCSS `//` comments in the target file.
.popover {
composes: tooltipContent from '../../../shared.module.scss';
--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);
}
@@ -217,8 +213,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 +405,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -25,15 +25,20 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -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/aiTraceUtils';
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}>
@@ -680,19 +696,19 @@ function Success(props: ISuccessProps): JSX.Element {
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: SpanV3): void => {
setSelectedSpan(span);
const params = getUnstableCurrentSearchParams();
if (span?.span_id) {
urlQuery.set('spanId', span?.span_id);
params.set('spanId', span?.span_id);
}
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: params.toString() }, { replace: true });
},
[setSelectedSpan, urlQuery, safeNavigate],
[setSelectedSpan, safeNavigate],
);
// Left side columns using TanStack React Table (extensible for future columns)
@@ -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

@@ -1,6 +1,10 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
// Local identity-proxy mock for this module so `styles.foo` resolves to
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
@@ -226,6 +230,11 @@ describe('Span Click User Flows', () => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('clicking span updates URL with spanId parameter', async () => {
@@ -260,7 +269,6 @@ describe('Span Click User Flows', () => {
) as HTMLElement;
await user.click(spanElement);
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringContaining('spanId=span-1'),
@@ -428,10 +436,6 @@ describe('Span Click User Flows', () => {
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringMatching(

View File

@@ -53,3 +53,11 @@ 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',
}
export const LLM_OBSERVABILITY_DOCS_URL =
'https://signoz.io/docs/llm-observability/';

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,87 +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 { 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 => {
@@ -91,421 +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 showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
const isWaterfallDocked = panelState.isOpen && isDocked;
const showRightDock = panelState.isOpen && isRightDocked;
const waterfallChildren = (
<ResizableBox
defaultHeight={300}
minHeight={150}
disabled={!isWaterfallDocked}
>
<TraceWaterfall
traceData={traceData}
isFetchingTraceData={isFetchingTraceData}
errorFetchingTraceData={errorFetchingTraceData}
traceId={traceId || ''}
interestedSpanId={interestedSpanId}
setInterestedSpanId={setInterestedSpanId}
uncollapsedNodes={uncollapsedNodes}
isFullDataLoaded={isFullDataLoaded}
localUncollapsedNodes={localUncollapsedNodes}
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
);
}, [isOverview, handleFilteredSpansChange]);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
<TraceDetailsWrapper>
<TraceDetailsHeader
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={hasTraceData}
/>
{isOverview ? (
<TraceDetailsOverview
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onHasTraceDataChange={setHasTraceData}
/>
{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,35 @@
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', 'Geist Mono', monospace;
word-break: break-all;
}
.tooltipSurface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.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);
/* Above the docked span details FloatingPanel (z-index 999). */
--tooltip-z-index: 1000;
backdrop-filter: blur(20px);
}

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ import {
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
TRACE_SCENARIOS,
type TraceScenario,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -103,6 +105,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
export const traceDetailsMocks = defineStoryMocks({
controls: {
scenario: choiceControl<TraceScenario>('Scenario', {
group: TRACE,
description:
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
options: TRACE_SCENARIOS,
value: 'checkout',
}),
spans: countControl('Spans', {
group: TRACE,
description:
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
handlers: (values, response) => {
const trace = {
scenario: values.scenario,
spans: values.spans,
errors: values.errors,
traceStart: traceStartOf(),
@@ -288,7 +298,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
config: (values) => ({
route: values.spanPanel
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
: `/trace/${STORY_TRACE_ID}`,
}),
effect: (values) => {

View File

@@ -13,7 +13,12 @@ import {
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import {
openRowMenu,
startDownload,
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/**
* One span per HTTP status badge the waterfall draws: informational, success,
* redirect, client and server errors, and a span with no status code and so no
* badge.
*/
export const StatusCodes: Story = {
args: {
scenario: 'status-codes',
spans: scenarioSpanCount('status-codes'),
spanPanel: false,
},
};
const AI_SPANS = scenarioSpanCount('ai');
/**
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
* token and cost totals in the header, the span panel on an LLM call with its
* tokens and cost, and that span's hover card open on its usage breakdown.
*/
export const AiTrace: Story = {
args: { scenario: 'ai', spans: AI_SPANS },
play: async ({ canvasElement }): Promise<void> => {
const row = await within(canvasElement).findByTestId(
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
},
};
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.

View File

@@ -23,6 +23,10 @@ interface SpanTemplate {
name: string;
kind: string;
failing?: boolean;
/** The span the panel opens on when nothing fails. */
focus?: boolean;
/** `response_status_code`; failing spans default to 503, the rest to 200. */
statusCode?: string;
attributes: Record<string, string | number | boolean>;
}
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
},
];
/** One span per badge colour, plus a span with no status code and so no badge. */
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['200', 'GET /status/ok'],
['101', 'GET /ws/upgrade'],
['201', 'POST /status/created'],
['204', 'DELETE /status/no-content'],
['301', 'GET /status/moved'],
['304', 'GET /status/not-modified'],
['400', 'POST /status/bad-request'],
['401', 'GET /status/unauthorized'],
['404', 'GET /status/not-found'],
['429', 'GET /status/rate-limited'],
['500', 'GET /status/internal-error'],
['502', 'GET /status/bad-gateway'],
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
* cover cache counted inside input (OpenAI), cache counted beside it
* (Anthropic), reasoning, and an embedding with input only.
*/
const AI_TEMPLATES: SpanTemplate[] = [
{
service: 'support-agent',
name: 'invoke_agent support-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'support-agent' },
},
{
service: 'support-agent',
name: 'chat gpt-4o',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'gpt-4o-2024-08-06',
'gen_ai.usage.input_tokens': 1840,
'gen_ai.usage.output_tokens': 212,
'gen_ai.usage.cache_read.input_tokens': 1024,
'gen_ai.usage.cache_creation.input_tokens': 0,
'signoz.gen_ai.usage.tokens.cost': 0.00566,
},
},
{
service: 'support-agent',
name: 'execute_tool search_docs',
kind: 'Internal',
attributes: { 'gen_ai.tool.name': 'search_docs' },
},
{
service: 'support-agent',
name: 'chat claude-sonnet-4',
kind: 'Client',
focus: true,
attributes: {
'gen_ai.request.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 86,
'gen_ai.usage.output_tokens': 640,
'gen_ai.usage.cache_read.input_tokens': 4200,
'gen_ai.usage.cache_creation.input_tokens': 1300,
'signoz.gen_ai.usage.tokens.cost': 0.01853,
},
},
{
service: 'docs-api',
name: 'GET /docs/search',
kind: 'Server',
attributes: {
'http.method': 'GET',
'http.route': '/docs/search',
'http.status_code': 200,
},
},
{
service: 'support-agent',
name: 'execute_tool fetch_ticket',
kind: 'Internal',
failing: true,
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
},
{
service: 'support-agent',
name: 'invoke_agent triage-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'triage-agent' },
},
{
service: 'support-agent',
name: 'embeddings text-embedding-3-small',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.usage.input_tokens': 312,
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
},
},
{
service: 'support-agent',
name: 'chat o3-mini',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.input_tokens': 920,
'gen_ai.usage.output_tokens': 1480,
'gen_ai.usage.reasoning.output_tokens': 1152,
'signoz.gen_ai.usage.tokens.cost': 0.007524,
},
},
];
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
checkout: SPAN_TEMPLATES,
'status-codes': STATUS_CODE_TEMPLATES,
ai: AI_TEMPLATES,
};
/** Spans it takes for the scenario to show every template once. */
export const scenarioSpanCount = (scenario: TraceScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
const TRACE_SERVICES = [
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
];
@@ -146,6 +289,7 @@ interface BuiltSpan {
}
interface TraceOptions {
scenario: TraceScenario;
spans: number;
errors: boolean;
/** Epoch milliseconds the root span starts at. */
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
`b17e${index.toString(16).padStart(12, '0')}`;
/**
* The span the story opens the panel on: the first failing one, so its events
* and error styling are on screen, and the root when nothing fails.
* The span the story opens the panel on: the scenario's focus span, else the
* first failing one, so its events and error styling are on screen, and the
* root when neither is in the trace.
*/
export const focusSpanIndex = (spans: number): number => {
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
export const focusSpanIndex = (
scenario: TraceScenario,
spans: number,
): number => {
const templates = SCENARIO_TEMPLATES[scenario];
const focus = templates.findIndex((template) => template.focus);
const failing = templates.findIndex((template) => template.failing);
const index = focus >= 0 ? focus : failing;
return failing >= 0 && failing < spans ? failing : 0;
return index >= 0 && index < spans ? index : 0;
};
const buildSpans = ({
scenario,
spans,
errors,
traceStart,
}: TraceOptions): BuiltSpan[] => {
const built: BuiltSpan[] = [];
const templates = SCENARIO_TEMPLATES[scenario];
for (let index = 0; index < spans; index += 1) {
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
const template = templates[index % templates.length];
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
// Children fit inside their parent's window, second child after the first,
// so no span outlives its parent and no two siblings overlap.
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
status_code: span.hasError ? 2 : 1,
status_code_string: span.hasError ? 'Error' : 'Ok',
status_message: span.hasError ? 'upstream unavailable' : '',
response_status_code: span.hasError ? '503' : '200',
response_status_code:
span.template.statusCode ?? (span.hasError ? '503' : '200'),
has_children: span.hasChildren,
sub_tree_node_count: span.subTreeNodeCount,
level: span.level,
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
};
};
const numberAttribute = (span: BuiltSpan, key: string): number =>
Number(span.template.attributes[key] ?? 0);
/** The trace's AI totals, summed over its spans the way the backend does. */
const traceAiSummary = (
spans: BuiltSpan[],
): GetTraceSummary200['data']['ai'] => {
const aiSpans = spans.filter(({ template }) =>
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
);
if (aiSpans.length === 0) {
return undefined;
}
const sum = (key: string): number =>
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
return {
tokens: {
input: sum('gen_ai.usage.input_tokens'),
output: sum('gen_ai.usage.output_tokens'),
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
},
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
ai: traceAiSummary(spans),
},
};
};
@@ -472,7 +657,7 @@ export const spanLogRows = (
): RawRow[] => {
const step = ROOT_DURATION_MS / Math.max(count, 1);
const spans = buildSpans(options);
const focus = spans[focusSpanIndex(options.spans)];
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
return Array.from({ length: count }, (_, index) => {
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;

View File

@@ -0,0 +1,51 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
AiSpanKind,
getAiSpanKind,
getTotalInputTokens,
isAiSpan,
} from '../aiTraceUtils';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});
describe('getTotalInputTokens', () => {
it.each([
// Cache reported inside input (OpenAI): input already includes it.
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
// Cache reported separately (Anthropic): add it.
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
[{ input: 10 }, 10],
[{ cacheRead: 600 }, 600],
])('%p → %p', (tokens, expected) => {
expect(getTotalInputTokens(tokens)).toBe(expected);
});
});

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