mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-23 02:20:41 +01:00
Compare commits
7 Commits
backup/tra
...
backup/tra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eb33ef05b | ||
|
|
2429549df9 | ||
|
|
681e96df82 | ||
|
|
ef36279e1c | ||
|
|
fc62476e6b | ||
|
|
c64af15ac6 | ||
|
|
f45d683ec2 |
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
152
frontend/src/pages/TraceDetailsV3/TraceDetailsThread/stories/TraceDetailsThread.stories.mocks.tsx
generated
Normal file
152
frontend/src/pages/TraceDetailsV3/TraceDetailsThread/stories/TraceDetailsThread.stories.mocks.tsx
generated
Normal 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 };
|
||||
@@ -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' },
|
||||
};
|
||||
488
frontend/src/pages/TraceDetailsV3/TraceDetailsThread/stories/__story_mockdata__/traceThread.ts
generated
Normal file
488
frontend/src/pages/TraceDetailsV3/TraceDetailsThread/stories/__story_mockdata__/traceThread.ts
generated
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user