Compare commits

..

7 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,13 +68,10 @@ function TraceDetailsHeader({
const isOverview = tab === TraceDetailsTab.Overview;
// Overview-only panels must not linger over another tab.
// 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(() => {
setIsPreviewFieldsOpen(false);
setIsAnalyticsOpen(false);
onFilteredSpansChange([], false);
}, [tab, onFilteredSpansChange]);
}, [tab]);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);

View File

@@ -383,16 +383,6 @@ describe('TraceDetailsHeader – tabs', () => {
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('clears the filter result when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();
baseProps.onFilteredSpansChange.mockClear();
await user.click(screen.getByTestId('trace-details-tab-thread'));
expect(baseProps.onFilteredSpansChange).toHaveBeenCalledWith([], false);
});
it('closes the Analytics panel when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { TraceDetailsTab } from './constants';
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
@@ -13,8 +13,6 @@ function TraceDetailsV3(): JSX.Element {
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState(false);
const [hasTraceData, setHasTraceData] = useState(false);
// Tracks which traceId the load event already fired for, so navigating
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
@@ -25,6 +23,14 @@ function TraceDetailsV3(): JSX.Element {
[],
);
// 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(() => {
if (!isOverview) {
handleFilteredSpansChange([], false);
}
}, [isOverview, handleFilteredSpansChange]);
return (
<TraceDetailsWrapper>
<TraceDetailsHeader

View File

@@ -39,7 +39,7 @@ const pageStory = storyMocks(traceDetailsMocks, { layout: 'app' });
* Route: `/trace/:id`.
*/
const meta = {
title: 'Pages/Traces/Trace Details',
title: 'Pages/Traces/Trace Details/Overview',
tags: ['play'],
component: TraceDetailsV3,
// The page reads the trace id out of the pathname, so it renders under its