Compare commits

...

4 Commits

Author SHA1 Message Date
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
Gaurav Tewari
edca5d2e3e refactor(trace-details): minor refactor 2026-10-19 11:56:16 +05:30
78 changed files with 4811 additions and 552 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

@@ -1,6 +1,7 @@
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { formUrlParams } from 'utils/traceUtils';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
@@ -20,6 +21,7 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
}
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
tab: TraceDetailsTab.Thread,
spanId,
levelUp: 0,
levelDown: 0,

View File

@@ -3,6 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
import { getMs } from 'utils/timeUtils';
import { useTimezone } from 'providers/Timezone';
@@ -44,7 +45,10 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
to={{
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
search: `?tab=${TraceDetailsTab.Thread}`,
}}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>

View File

@@ -0,0 +1,58 @@
import { MouseEvent, ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
import { useCopySpanLink } from '../useCopySpanLink';
const mockCopy = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn() },
}));
// The router only knows the stale URL; nuqs has since written ?tab=thread.
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
{children}
</MemoryRouter>
);
const clickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
} as unknown as MouseEvent<HTMLElement>;
describe('useCopySpanLink', () => {
beforeEach(() => {
mockCopy.mockClear();
__setSearchParamsGetterForTest(
() => new URLSearchParams('tab=thread&spanId=old'),
);
});
afterEach(() => {
__resetSearchParamsGetter();
});
it('builds the link from the live URL, keeping its tab', () => {
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
wrapper,
});
result.current.onSpanCopy(clickEvent);
const link = new URL(mockCopy.mock.calls[0][0]);
expect(link.pathname).toBe('/trace/abc');
expect(link.searchParams.get('tab')).toBe('thread');
expect(link.searchParams.get('spanId')).toBe('new');
});
});

View File

@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import useUrlQuery from 'hooks/useUrlQuery';
import { getAbsoluteUrl } from 'utils/basePath';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
@@ -15,7 +15,6 @@ interface SpanLike {
export const useCopySpanLink = (
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
@@ -28,21 +27,22 @@ export const useCopySpanLink = (
event.preventDefault();
event.stopPropagation();
urlQuery.delete('spanId');
const params = getUnstableCurrentSearchParams();
params.delete('spanId');
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
params.set('spanId', id);
}
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', {
position: 'top-right',
});
},
[span, urlQuery, pathname, setCopy],
[span, pathname, setCopy],
);
return {

View File

@@ -0,0 +1,44 @@
import ReactMarkdown, { uriTransformer } from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Model output is untrusted: never add `rehype-raw`, so raw HTML renders as text.
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS: Components = {
a: ({ node: _node, children, ...props }): JSX.Element => (
<a {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
// Images become links so viewing a message never fetches a third-party URL.
img: ({ src, alt }): JSX.Element => (
<a href={src} target="_blank" rel="noopener noreferrer">
{alt || src}
</a>
),
};
interface AIMarkdownProps {
content: string;
className?: string;
}
function AIMarkdown({ content, className }: AIMarkdownProps): JSX.Element {
return (
<ReactMarkdown
className={className}
remarkPlugins={MD_PLUGINS}
// Images render as links, so their URIs need the same sanitising links get.
transformImageUri={uriTransformer}
components={MD_COMPONENTS}
>
{content}
</ReactMarkdown>
);
}
AIMarkdown.defaultProps = {
className: undefined,
};
export default AIMarkdown;

View File

@@ -0,0 +1,376 @@
// Design token Paragraph/Base/400.
@mixin paragraph-base {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-20);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.roleHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
color: var(--l1-foreground);
}
// Typography sets size, weight and line height; the mixin adds the rest.
.roleLabel {
@include paragraph-base;
}
.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 {
@include paragraph-base;
color: var(--l2-foreground);
word-break: break-word;
:global(p) {
margin: 0 0 var(--spacing-4);
}
:global(p:last-child) {
margin-bottom: 0;
}
:global(:is(ol, ul)) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-10);
}
:global(li::marker) {
color: var(--l2-foreground);
}
:global(:is(h1, h2, h3, h4, h5, h6)) {
margin: var(--spacing-6) 0 var(--spacing-4);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-20);
color: var(--l1-foreground);
}
:global(:is(h1, h2)) {
font-size: var(--periscope-font-size-medium);
}
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
:global(strong) {
font-weight: var(--font-weight-semibold);
color: var(--l1-foreground);
}
:global(a) {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
:global(code:not(pre code)) {
padding: 0 var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l3-background);
font-family: var(--font-mono);
font-size: 0.92em;
color: var(--l1-foreground);
}
:global(pre) {
position: relative;
margin: 0 0 var(--spacing-4);
}
// The highlighter sets its box with inline styles; syntax colours stay its own.
:global(pre > div:not(.code-copy-btn)) {
margin: 0 !important;
padding: var(--spacing-4) var(--spacing-5) !important;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2) !important;
background: var(--l1-background) !important;
overflow-x: auto;
}
:global(pre code) {
font-family: var(--font-mono) !important;
}
:global(blockquote) {
margin: 0 0 var(--spacing-4);
padding-left: var(--spacing-5);
border-left: 2px solid var(--l3-border);
color: var(--l2-foreground);
}
:global(hr) {
margin: var(--spacing-6) 0;
border: none;
border-top: 1px solid var(--l2-border);
}
}
.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);
color: var(--l2-foreground);
min-width: 0;
@include paragraph-base;
&.isPlain {
padding: 0;
background: none;
}
}
.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);
}
&[data-tone='info'] {
background: var(--accent-primary);
}
}
.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 {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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-self: flex-start;
max-width: 100%;
min-width: 0;
padding: 0;
border: none;
border-radius: var(--radius-2);
background: none;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
&:hover {
.chip {
background: var(--l3-background);
}
.toolLinkIcon {
color: var(--primary);
}
}
}
.toolLinkIcon {
flex-shrink: 0;
color: var(--l2-foreground);
}
.collapsible {
min-width: 0;
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
gap: var(--spacing-2);
padding: 0 !important;
font-size: var(--font-size-xs);
color: var(--l2-foreground);
}
// Unboxed: the chevron sits right after the label.
:global(.ant-collapse-header-text) {
flex: none !important;
margin-inline-end: 0 !important;
color: var(--l2-foreground);
@include paragraph-base;
}
:global(.ant-collapse-expand-icon) {
height: auto !important;
padding-inline-start: 0 !important;
}
:global(.ant-collapse-content-box) {
display: flex;
flex-direction: column;
min-width: 0;
padding: var(--spacing-4) 0 0 !important;
}
&.boxed {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-header) {
padding: var(--spacing-3) var(--spacing-5) !important;
}
:global(.ant-collapse-header-text) {
flex: auto !important;
}
:global(.ant-collapse-content-box) {
padding: 0 var(--spacing-5) var(--spacing-4) !important;
}
// The block is the box; nested text drops its own.
.textBox {
padding: 0;
border: none;
background: none;
}
}
}
.collapsibleChevron {
display: flex;
color: var(--l2-foreground);
}
.jsonBlock {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
min-width: 0;
}
.json {
position: relative;
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;
}
}
.jsonCollapsed {
overflow: hidden;
}
.jsonCopy {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-2);
z-index: 1;
}

View File

@@ -0,0 +1,98 @@
import { Badge } from '@signozhq/ui/badge';
import { MessageType, ThreadMessage } from './types';
import CollapsibleBlock from './CollapsibleBlock';
import MessagePart from './MessagePart';
import PartChip from './PartChip';
import RoleLabel from './RoleLabel';
import { getFinishReasonTone, getRoleLabel } from './utils';
import styles from './AIThreadMessage.module.scss';
export interface AIThreadMessageProps {
message: ThreadMessage;
messageType?: MessageType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function AIThreadMessage({
message,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: AIThreadMessageProps): JSX.Element {
const { role, parts: messageParts, finish_reason: finishReason } = message;
const roleLabel = getRoleLabel(role);
const tone = getFinishReasonTone(finishReason);
const isOutput = messageType === MessageType.Output;
const parts = (
<div className={styles.parts}>
{messageParts.length === 0 ? (
<span className={styles.empty}>No content</span>
) : (
messageParts.map((part, index) => (
<MessagePart
// eslint-disable-next-line react/no-array-index-key
key={index}
part={part}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
))
)}
</div>
);
return (
<div
className={styles.message}
data-testid={`ai-thread-message-${role || 'unknown'}`}
>
{role === 'system' || role === 'developer' ? (
<CollapsibleBlock
label={roleLabel && <RoleLabel label={roleLabel} />}
testId="ai-message-system-toggle"
>
{parts}
</CollapsibleBlock>
) : (
<>
{(roleLabel || isOutput) && (
<div className={styles.roleHeader}>
{roleLabel && <RoleLabel label={roleLabel} />}
{isOutput && (
<Badge
variant="outlined"
color="secondary"
textTransform="none"
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 = {
messageType: MessageType.Input,
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default AIThreadMessage;

View File

@@ -0,0 +1,54 @@
import { MouseEvent, ReactNode } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import cx from 'classnames';
import styles from './AIThreadMessage.module.scss';
const PANEL_KEY = 'content';
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 {
// Toggling must not count as a click on the message card; the body still does.
const stopHeaderClick = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
event.stopPropagation();
}
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div onClick={stopHeaderClick}>
<Collapse
ghost
className={cx(styles.collapsible, isBoxed && styles.boxed)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.collapsibleChevron} data-testid={testId}>
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</span>
)}
items={[{ key: PANEL_KEY, label, children }]}
/>
</div>
);
}
CollapsibleBlock.defaultProps = {
isBoxed: false,
testId: undefined,
};
export default CollapsibleBlock;

View File

@@ -0,0 +1,39 @@
import { MouseEvent, useState } from 'react';
import AIMarkdown from './AIMarkdown';
import { MAX_TEXT_LENGTH } from './utils';
import styles from './AIThreadMessage.module.scss';
interface ExpandableTextProps {
text: string;
}
function ExpandableText({ text }: 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 (
<>
<AIMarkdown className={styles.text} content={visibleText} />
{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>
)}
</>
);
}
export default ExpandableText;

View File

@@ -0,0 +1,79 @@
import { MouseEvent, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { PrettyView } from 'periscope/components/PrettyView';
import { useResizeObserver } from 'hooks/useDimensions';
import styles from './AIThreadMessage.module.scss';
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
const NO_ACTIONS = { leaf: [], nested: [] };
// 10 tree rows: 18px line-height plus 2px padding top and bottom.
const COLLAPSED_MAX_HEIGHT = 10 * 22;
interface JsonBlockProps {
data: object;
}
// Expanding tree nodes must not count as a click on the message.
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
const treeRef = useRef<HTMLDivElement>(null);
const { height: treeHeight } = useResizeObserver(treeRef);
const isLong = treeHeight > COLLAPSED_MAX_HEIGHT;
const isClamped = isLong && !isExpanded;
const stopToggleClick = (event: MouseEvent): void => {
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
event.stopPropagation();
}
};
const handleToggle = (event: MouseEvent): void => {
event.stopPropagation();
setIsExpanded((prev) => !prev);
};
return (
<div className={styles.jsonBlock}>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={cx(styles.json, isClamped && styles.jsonCollapsed)}
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
onClick={stopToggleClick}
data-testid="ai-json-block"
>
<span className={styles.jsonCopy}>
<CopyButton
value={text}
size={12}
ariaLabel="Copy JSON"
testId="ai-json-copy"
/>
</span>
<div ref={treeRef}>
<PrettyView
data={data as Record<string, unknown>}
searchable={false}
drawerKey="ai-thread-message"
visibleActions={NO_ACTIONS}
/>
</div>
</div>
{isLong && (
<button
type="button"
className={styles.showMore}
onClick={handleToggle}
data-testid="ai-json-show-more"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
);
}
export default JsonBlock;

View File

@@ -0,0 +1,105 @@
import { MessagePart as MessagePartType } from './types';
import JsonBlock from './JsonBlock';
import ThinkingPart from './parts/ThinkingPart';
import ToolCallPart from './parts/ToolCallPart';
import ToolResultPart from './parts/ToolResultPart';
import TextContent from './TextContent';
import {
isEmptyPart,
toDisplayText,
toToolCallKey,
truncateBlobContent,
} from './utils';
import styles from './AIThreadMessage.module.scss';
const EMPTY_PART = (
<span className={styles.empty} data-testid="ai-message-empty-part">
No content
</span>
);
interface MessagePartProps {
part: MessagePartType;
canLinkToToolCall?: (toolCallKey: string) => boolean;
onToolLinkClick?: (toolCallKey: string) => void;
}
function MessagePart({
part,
canLinkToToolCall,
onToolLinkClick,
}: MessagePartProps): JSX.Element {
if (isEmptyPart(part)) {
return EMPTY_PART;
}
switch (part.type) {
case 'text':
return <TextContent text={part.content} />;
case 'reasoning':
return <ThinkingPart content={part.content} />;
case 'compaction':
return (
<ThinkingPart
label="Compaction"
content={part.content ?? undefined}
testId="ai-message-compaction-toggle"
/>
);
case 'tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.arguments}
type={part.type}
/>
);
case 'server_tool_call':
return (
<ToolCallPart
id={part.id ?? ''}
name={part.name ?? ''}
args={part.server_tool_call}
type={part.type}
/>
);
case 'tool_call_response':
case 'server_tool_call_response': {
const toolCallId = part.id ?? '';
const toolCallKey = toToolCallKey(
toolCallId,
part.type === 'server_tool_call_response',
);
return (
<ToolResultPart
toolCallId={toolCallId}
toolCallKey={toolCallKey}
type={part.type}
content={toDisplayText(
part.type === 'tool_call_response'
? part.response
: part.server_tool_call_response,
)}
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
onLinkClick={onToolLinkClick}
/>
);
}
case 'blob':
case 'file':
case 'uri':
return <JsonBlock data={truncateBlobContent(part)} />;
case 'generic':
return <TextContent text={toDisplayText(part.content)} />;
default:
return <TextContent text={toDisplayText(part)} />;
}
}
MessagePart.defaultProps = {
canLinkToToolCall: undefined,
onToolLinkClick: undefined,
};
export default MessagePart;

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
import cx from 'classnames';
import { MessageTone } from './utils';
import styles from './AIThreadMessage.module.scss';
interface PartChipProps {
label: string;
value?: ReactNode;
tone?: MessageTone;
// Plain: no chip background, for headers like a tool call's.
isPlain?: boolean;
testId?: string;
children?: ReactNode;
}
function PartChip({
label,
value,
tone = 'success',
isPlain = false,
testId,
children,
}: PartChipProps): JSX.Element {
return (
<span
className={cx(styles.chip, isPlain && styles.isPlain)}
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',
isPlain: false,
testId: undefined,
children: undefined,
};
export default PartChip;

View File

@@ -0,0 +1,18 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './AIThreadMessage.module.scss';
interface RoleLabelProps {
label: string;
}
/** Message role in the design's Paragraph/Base/400 style. */
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
return (
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
{label}
</Typography.Text>
);
}
export default RoleLabel;

View File

@@ -0,0 +1,27 @@
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;
}
/** Renders JSON text as a tree, anything else as markdown. */
function TextContent({ text }: TextContentProps): JSX.Element {
const json = useMemo(() => parseJsonObject(text), [text]);
if (json) {
return <JsonBlock data={json} />;
}
return (
<div className={styles.textBox}>
<ExpandableText text={text} />
</div>
);
}
export default TextContent;

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,34 @@
import CollapsibleBlock from '../CollapsibleBlock';
import TextContent from '../TextContent';
import styles from '../AIThreadMessage.module.scss';
interface ThinkingPartProps {
content?: string;
label?: string;
testId?: string;
}
function ThinkingPart({
content,
label = 'Thinking',
testId = 'ai-message-thinking-toggle',
}: ThinkingPartProps): JSX.Element {
return (
<CollapsibleBlock label={label} isBoxed testId={testId}>
{content ? (
<TextContent text={content} />
) : (
<span className={styles.empty}> - </span>
)}
</CollapsibleBlock>
);
}
ThinkingPart.defaultProps = {
content: undefined,
label: 'Thinking',
testId: 'ai-message-thinking-toggle',
};
export default ThinkingPart;

View File

@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import JsonBlock from '../JsonBlock';
import PartChip from '../PartChip';
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
import styles from '../AIThreadMessage.module.scss';
interface ToolCallPartProps {
id: string;
name: string;
args: unknown;
type: 'tool_call' | 'server_tool_call';
}
function ToolCallPart({
id,
name,
args,
type,
}: 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]: toToolCallKey(id, type === 'server_tool_call'),
};
return (
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
<PartChip
label={type}
tone="info"
isPlain
value={
<>
<span className={styles.chipKey}>name :</span>
<span className={styles.toolName}>{name}</span>
</>
}
/>
{json ? (
<JsonBlock data={json} />
) : (
<span className={styles.empty}> - </span>
)}
</div>
);
}
export default ToolCallPart;

View File

@@ -0,0 +1,80 @@
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;
toolCallKey: string;
type: 'tool_call_response' | 'server_tool_call_response';
name?: string;
content: string;
isError?: boolean;
isLinkEnabled: boolean;
onLinkClick?: (toolCallKey: string) => void;
}
function ToolResultPart({
toolCallId,
toolCallKey,
type,
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?.(toolCallKey);
};
const chip = (
<PartChip
label={type}
value={isInline ? content || '…' : name || toolCallId}
tone={isError ? 'error' : 'success'}
>
{isLinkEnabled && (
<CornerUpRight size={12} className={styles.toolLinkIcon} />
)}
</PartChip>
);
return (
<div className={styles.toolResult} data-testid="ai-tool-result">
{isLinkEnabled ? (
<button
type="button"
className={styles.toolLink}
onClick={handleLinkClick}
title="Go to tool call"
data-testid="ai-tool-result-link"
>
{chip}
</button>
) : (
chip
)}
{!isInline && <TextContent text={content} />}
</div>
);
}
ToolResultPart.defaultProps = {
name: undefined,
isError: false,
onLinkClick: undefined,
};
export default ToolResultPart;

View File

@@ -0,0 +1,57 @@
import type {
AiobservabilitytypesBlobPartDTO,
AiobservabilitytypesCompactionPartDTO,
AiobservabilitytypesFilePartDTO,
AiobservabilitytypesOutputMessageDTO,
AiobservabilitytypesReasoningPartDTO,
AiobservabilitytypesServerToolCallPartDTO,
AiobservabilitytypesServerToolCallResponsePartDTO,
AiobservabilitytypesTextPartDTO,
AiobservabilitytypesToolCallRequestPartDTO,
AiobservabilitytypesToolCallResponsePartDTO,
AiobservabilitytypesUriPartDTO,
} from 'api/generated/services/sigNoz.schemas';
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
type Part<T extends { type: string }> = Omit<T, 'type'> & {
type: `${T['type']}`;
};
// Keys the schema marks required may still be missing.
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
type: `${T['type']}`;
};
/** OTel GenAI message part; `text` content is always normalised to a string. */
export type MessagePart =
| Part<AiobservabilitytypesTextPartDTO>
| LoosePart<AiobservabilitytypesReasoningPartDTO>
| LoosePart<AiobservabilitytypesCompactionPartDTO>
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
| LoosePart<AiobservabilitytypesBlobPartDTO>
| LoosePart<AiobservabilitytypesFilePartDTO>
| LoosePart<AiobservabilitytypesUriPartDTO>
| { type: 'generic'; content?: unknown };
export type MessagePartType = MessagePart['type'];
export type MediaMessagePart = Extract<
MessagePart,
{ type: 'blob' | 'file' | 'uri' }
>;
// role: system | developer | user | assistant | tool; any other value renders headerless.
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
export type ThreadMessage = Partial<
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
> & {
parts: MessagePart[];
};
export enum MessageType {
Input = 'input',
Output = 'output',
}

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 {
canLinkToToolCall: (toolCallKey: string) => boolean;
onToolLinkClick: (toolCallKey: string) => void;
}
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
export function useToolCallLinks(
rootRef: RefObject<HTMLElement>,
toolCallKeys: Set<string>,
): ToolCallLinks {
const canLinkToToolCall = useCallback(
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
[toolCallKeys],
);
const onToolLinkClick = useCallback(
(toolCallKey: string): void => {
const target = rootRef.current?.querySelector<HTMLElement>(
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
);
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 { canLinkToToolCall, onToolLinkClick };
}

View File

@@ -0,0 +1,96 @@
import { attempt, isError, isObjectLike } from 'lodash-es';
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
export const MAX_TEXT_LENGTH = 1000;
const MAX_BLOB_PREVIEW_LENGTH = 100;
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',
developer: 'Developer',
user: 'User',
assistant: 'Assistant',
tool: 'Tool',
};
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
// Provider values the backend could not map fall back to info.
const FINISH_REASON_TONES: Record<string, MessageTone> = {
stop: 'success',
tool_call: 'success',
length: 'warning',
content_filter: 'warning',
compaction: 'info',
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 | null): MessageTone {
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
}
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
export function parseJsonObject(text: string): object | undefined {
const parsed: unknown = attempt(JSON.parse, text);
return isObjectLike(parsed) && !isError(parsed)
? (parsed as object)
: undefined;
}
export function toDisplayText(value: unknown): string {
if (value === undefined || value === null) {
return '';
}
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
}
/** Text and generic parts with only whitespace have nothing to render. */
export function isEmptyPart(part: MessagePart): boolean {
if (part.type === 'text') {
return part.content.trim() === '';
}
if (part.type === 'generic') {
return toDisplayText(part.content).trim() === '';
}
return false;
}
/** Blob content is inline base64; only a prefix is shown. */
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
if (
part.type !== 'blob' ||
!part.content ||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
) {
return part;
}
return {
...part,
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
};
}
// Server and client calls can share an id, so links match on kind too.
export function toToolCallKey(id: string, isServer: boolean): string {
return `${isServer ? 'server' : 'client'}:${id}`;
}
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
return messages.flatMap((message) =>
message.parts.flatMap((part) =>
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
: [],
),
);
}

View File

@@ -16,7 +16,8 @@ function EntityMetadataItem({
children,
}: EntityMetadataItemProps): JSX.Element {
return (
<Tooltip title={tooltip}>
// Below the row: above it the tooltip covers the Overview / Thread tabs.
<Tooltip title={tooltip} side="bottom">
<span className={styles.item}>
{icon}
<Typography.Text as="span">{children}</Typography.Text>

View File

@@ -0,0 +1,43 @@
@use '../../../../styles/scrollbar' as *;
.accordion {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l2-background);
:global(.ant-collapse-item) {
border: none;
}
:global(.ant-collapse-header) {
align-items: center !important;
height: 40px;
padding: 0 var(--spacing-4) !important;
}
:global(.ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--l2-border);
}
:global(.ant-collapse-content-box) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}
.title {
display: flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.chevron {
display: flex;
color: var(--l2-foreground);
}

View File

@@ -0,0 +1,103 @@
import { MouseEvent, ReactNode, useState } from 'react';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
import styles from './MessagesAccordion.module.scss';
const PANEL_KEY = 'messages';
interface MessagesAccordionProps extends Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
> {
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (!canFormat && !raw) {
return null;
}
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
return (
<section data-testid={testId}>
<Collapse
className={styles.accordion}
activeKey={isOpen ? [PANEL_KEY] : []}
onChange={(): void => setIsOpen((prev) => !prev)}
expandIconPosition="end"
expandIcon={({ isActive }): JSX.Element => (
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
)}
items={[
{
key: PANEL_KEY,
label: (
<span className={styles.title}>
{icon}
{title}
</span>
),
extra: isOpen &&
canFormat && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={effectiveView}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
</span>
),
children: (
<MessagesSection
view={effectiveView}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
),
},
]}
/>
</section>
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -0,0 +1,7 @@
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex-shrink: 0;
min-width: 0;
}

View File

@@ -0,0 +1,58 @@
import { useMemo, useRef } from 'react';
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
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 rootRef = useRef<HTMLDivElement>(null);
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
rootRef,
toolCallKeys,
);
if (!hasAnyMessages(data)) {
return null;
}
return (
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
<MessagesAccordion
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesAccordion
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
export default SpanAIMessages;

View File

@@ -34,6 +34,15 @@
min-width: 0;
}
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
.tabAiMessages {
margin-bottom: 16px;
&:empty {
display: none;
}
}
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
// a true fixed footer flush with the panel's bottom edge — always visible
// regardless of how the body is scrolled.

View File

@@ -48,6 +48,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';
@@ -281,6 +282,7 @@ function SpanDetailsContent({
traceEndTime={traceEndTime}
/>
);
const aiMessages = <SpanAIMessages span={selectedSpan} />;
const eventsCount = selectedSpan.events?.length || 0;
const spanDetailTabs: TabsItemProps[] = [
@@ -291,6 +293,7 @@ function SpanDetailsContent({
children: (
<>
{isWide && summary}
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
<DataViewer
data={spanDisplayData}
drawerKey="trace-details"
@@ -369,6 +372,7 @@ function SpanDetailsContent({
<>
<div className={styles.panelBody} ref={bodyRef}>
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
{!isWide && aiMessages}
<div className={styles.tabsSection}>
<Tabs

View File

@@ -0,0 +1,68 @@
import AIThreadMessage, {
AIThreadMessageProps,
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
type MessageProps = Pick<
AIThreadMessageProps,
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
>;
interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
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}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
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/spanMessages';
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,51 @@
.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 {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
text-align: center;
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface SpanMessagesProps extends Pick<
AIThreadMessageProps,
'canLinkToToolCall' | '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,
canLinkToToolCall,
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
data-testid="span-messages-empty"
>
This span has no input or output.
</div>
);
}
if (!isFormatted) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
/>
</div>
);
}
return (
<div className={cx(styles.root, className)}>
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
SpanMessages.defaultProps = {
className: undefined,
};
export default SpanMessages;

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

@@ -29,6 +29,10 @@
}
}
.tabs {
flex-shrink: 0;
}
.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 { Tooltip } from '@signozhq/ui/tooltip';
@@ -13,16 +13,20 @@ 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';
@@ -60,6 +64,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);
@@ -93,13 +105,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}>
@@ -120,9 +133,10 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs />
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
<div
className={cx(
styles.filterSection,
@@ -138,6 +152,7 @@ function TraceDetailsHeader({
icon
color="secondary"
aria-label="Analytics"
testId="trace-analytics-button"
onClick={handleToggleAnalytics}
>
<ChartPie size={14} />
@@ -172,16 +187,20 @@ 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,
)}
@@ -189,13 +208,11 @@ function TraceDetailsHeader({
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,43 @@
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { TraceDetailsTab } from '../constants';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
void setTab(value as TraceDetailsTab);
};
return (
<div className={styles.tabs}>
<ToggleGroup
variant="outlined"
color="secondary"
type="single"
value={tab}
onChange={handleChange}
size="sm"
items={TAB_ITEMS}
/>
</div>
);
}
export default TraceDetailsTabs;

View File

@@ -50,6 +50,7 @@ function TraceOptionsMenu({
type: 'item',
value: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
testId: 'toggle-trace-details',
onClick: onToggleTraceDetails,
},
{
@@ -127,6 +128,7 @@ function TraceOptionsMenu({
icon
color="secondary"
aria-label="Trace options"
testId="trace-options-button"
>
<Settings2 size={14} />
</Button>

View File

@@ -2,6 +2,7 @@ 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';
@@ -315,3 +316,88 @@ 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.getByTestId('trace-analytics-button')).toBeInTheDocument();
expect(screen.getByTestId('trace-options-button')).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.queryByTestId('trace-analytics-button'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('trace-options-button')).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.getByTestId('trace-options-button'));
await user.click(await screen.findByTestId('toggle-trace-details'));
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.getByTestId('trace-analytics-button'));
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,505 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
MutableRefObject,
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 {
SPAN_DETAILS_PANEL_MIN_WIDTH,
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;
dataLoadedFiredForRef: MutableRefObject<string>;
}
function TraceDetailsOverview({
filteredSpanIds,
isFilterActive,
onHasTraceDataChange,
dataLoadedFiredForRef,
}: 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 || '');
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,
dataLoadedFiredForRef,
]);
// 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 = SPAN_DETAILS_PANEL_MIN_WIDTH;
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,29 @@
.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);
}
.highlight {
color: var(--l1-foreground);
}

View File

@@ -0,0 +1,43 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
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}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
</div>
<Button
variant="solid"
color="secondary"
size="md"
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,23 @@
import { Skeleton } from '@signozhq/ui/skeleton';
import styles from './ThreadSpanList.module.scss';
interface ThreadPageLoaderProps {
testId: string;
}
// Mirrors a span card: a short message, then an output with a block and a chip.
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
return (
<div className={styles.pageLoader} data-testid={testId}>
<Skeleton active title={{ width: 48 }} paragraph={{ rows: 1 }} />
<Skeleton
active
title={{ width: 112 }}
paragraph={{ rows: 3, width: ['100%', '100%', 160] }}
/>
</div>
);
}
export default ThreadPageLoader;

View File

@@ -0,0 +1,29 @@
.card {
position: relative;
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);
}
&:focus-visible {
outline: 1px solid var(--primary);
outline-offset: var(--spacing-1);
}
&.isSelected::before {
content: '';
position: absolute;
top: -1px;
bottom: -1px;
left: -1px;
width: 2px;
border-radius: var(--radius-2) 0 0 var(--radius-2);
background: var(--primary-background);
}
}

View File

@@ -0,0 +1,67 @@
import { KeyboardEvent, memo, useMemo, useRef } from 'react';
import cx from 'classnames';
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
import SpanMessages from '../SpanMessages/SpanMessages';
import { ThreadSpan, ThreadView } from './types';
import { buildToolCallIndex } from './utils';
import styles from './ThreadSpanCard.module.scss';
interface ThreadSpanCardProps {
span: ThreadSpan;
view: ThreadView;
isSelected: boolean;
onSelect: (span: ThreadSpan) => void;
}
function ThreadSpanCard({
span,
view,
isSelected,
onSelect,
}: ThreadSpanCardProps): JSX.Element {
const cardRef = useRef<HTMLDivElement>(null);
// A result links only to a call in its own span.
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
cardRef,
toolCallKeys,
);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
// Keys pressed on the card's own controls stay with them.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(span);
}
};
return (
<div
ref={cardRef}
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- a <button> can't nest the card's own buttons
role="button"
tabIndex={0}
aria-pressed={isSelected}
aria-label={`Span ${span.name}`}
className={cx(styles.card, isSelected && styles.isSelected)}
onClick={(): void => onSelect(span)}
onKeyDown={handleKeyDown}
data-span-id={span.span_id}
data-testid="thread-span-card"
>
<SpanMessages
span={span}
view={view}
canLinkToToolCall={canLinkToToolCall}
onToolLinkClick={onToolLinkClick}
/>
</div>
);
}
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 [width, setWidth] = useState(RIGHT_DOCK_MIN);
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const { data: traceSummary } = useTraceSummary(traceId);
const panel = (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={span}
variant={variant}
onVariantChange={onVariantChange}
traceStartTime={traceSummary?.startTimestampMillis}
traceEndTime={traceSummary?.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,28 @@
@use '../../../styles/scrollbar' as *;
.list {
@include custom-scrollbar;
flex: 1;
min-height: 0;
}
// Padding, not margin: Virtuoso measures item boxes.
.item {
padding: var(--spacing-6) var(--spacing-8) 0;
}
.footer {
padding-bottom: var(--spacing-6);
}
.pageLoader {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-10);
padding: var(--spacing-8);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}

View File

@@ -0,0 +1,94 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import ThreadPageLoader from './ThreadPageLoader';
import { ThreadSpan } from './types';
import styles from './ThreadSpanList.module.scss';
// Virtuoso needs a positive index it can decrease as older spans are prepended.
const FIRST_ITEM_INDEX_BASE = 1_000_000;
const LOAD_AHEAD_PX = 200;
interface ThreadSpanListProps {
spans: ThreadSpan[];
spansAboveFirstPage: number;
initialSpanIndex?: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
renderSpan: (span: ThreadSpan) => ReactNode;
}
function ThreadSpanList({
spans,
spansAboveFirstPage,
initialSpanIndex,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
renderSpan,
}: ThreadSpanListProps): JSX.Element {
const handleEndReached = useCallback((): void => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const handleStartReached = useCallback((): void => {
if (hasPreviousPage && !isFetchingPreviousPage) {
void fetchPreviousPage();
}
}, [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage]);
const components = useMemo(
() => ({
Header: (): JSX.Element | null =>
isFetchingPreviousPage ? (
<div className={styles.item}>
<ThreadPageLoader testId="thread-loading-previous" />
</div>
) : null,
Footer: (): JSX.Element => (
<div className={styles.footer}>
{isFetchingNextPage && (
<div className={styles.item}>
<ThreadPageLoader testId="thread-loading-next" />
</div>
)}
</div>
),
}),
[isFetchingPreviousPage, isFetchingNextPage],
);
return (
<Virtuoso
className={styles.list}
data-testid="thread-span-list"
data={spans}
// 0 is allowed, negative is not (Virtuoso warns); the base fits 1,000,000 prepended spans.
firstItemIndex={FIRST_ITEM_INDEX_BASE - spansAboveFirstPage}
// Omitted, not undefined: an explicit undefined blanks the list, and 0 resets an early scroll.
{...(initialSpanIndex === undefined
? {}
: { initialTopMostItemIndex: initialSpanIndex })}
increaseViewportBy={LOAD_AHEAD_PX}
computeItemKey={(_, span): string => span.span_id}
itemContent={(_, span): JSX.Element => (
<div className={styles.item}>{renderSpan(span)}</div>
)}
startReached={handleStartReached}
endReached={handleEndReached}
components={components}
/>
);
}
export default ThreadSpanList;

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,50 @@
import { Button } from '@signozhq/ui/button';
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
import styles from './ThreadSpanNotFound.module.scss';
interface ThreadSpanNotFoundProps {
onGoBack: () => void;
onShowThread: () => void;
}
function ThreadSpanNotFound({
onGoBack,
onShowThread,
}: ThreadSpanNotFoundProps): JSX.Element {
return (
<div className={styles.root} data-testid="thread-span-not-found">
<span className={styles.icon}>
<SearchX size={32} />
</span>
<span className={styles.title}>Span not found in this thread</span>
<span className={styles.description}>
The requested span isn&apos;t part of this trace&apos;s AI thread.
</span>
<div className={styles.actions}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<ArrowLeft size={14} />}
onClick={onGoBack}
testId="thread-span-not-found-go-back"
>
Go back
</Button>
<Button
variant="solid"
color="primary"
size="md"
prefix={<MessagesSquare size={14} />}
onClick={onShowThread}
testId="thread-span-not-found-show-thread"
>
Show thread
</Button>
</div>
</div>
);
}
export default ThreadSpanNotFound;

View File

@@ -0,0 +1,46 @@
import { ToggleGroup } 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 (
<ToggleGroup
variant="outlined"
color="secondary"
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,165 @@
import { useParams } from 'react-router-dom';
import { Callout } from '@signozhq/ui/callout';
import history, { hasInAppHistory } from 'lib/history';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { TraceDetailsTab } from '../constants';
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
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 { InterestedSpanStatus } from './types';
import { getInterestedSpanStatus } from './utils';
import styles from './TraceDetailsThread.module.scss';
const INITIAL_LOADER_COUNT = 4;
function TraceDetailsThread(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const [, setTab] = useTraceDetailsTab();
const [view, setView] = useThreadView();
const {
interestedSpanId,
clearInterestedSpan,
selectedSpanId,
selectSpan,
panelState,
} = useThreadSpanSelection();
const {
spans,
spansAboveFirstPage,
isLoading,
isError,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
} = useTraceThread({ traceId: traceId || '', interestedSpanId });
const interestedSpanStatus = getInterestedSpanStatus(
interestedSpanId,
spans,
isLoading,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,
);
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 &&
interestedSpanStatus !== InterestedSpanStatus.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 (interestedSpanStatus === InterestedSpanStatus.NotFound) {
return (
<ThreadSpanNotFound
onGoBack={(): void => {
// go back to the previous page
if (hasInAppHistory()) {
history.goBack();
} else {
void setTab(TraceDetailsTab.Overview);
}
}}
onShowThread={clearInterestedSpan}
/>
);
}
if (hasNoThread) {
return <ThreadNoAiSpans />;
}
return (
<>
{interestedSpanStatus === InterestedSpanStatus.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
key={interestedSpanId}
spans={spans}
spansAboveFirstPage={spansAboveFirstPage}
initialSpanIndex={
interestedSpanIndex === -1 ? undefined : interestedSpanIndex
}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
isFetchingNextPage={isFetchingNextPage}
isFetchingPreviousPage={isFetchingPreviousPage}
fetchNextPage={fetchNextPage}
fetchPreviousPage={fetchPreviousPage}
renderSpan={(span): JSX.Element => (
<ThreadSpanCard
span={span}
view={view}
isSelected={span.span_id === selectedSpanId}
onSelect={selectSpan}
/>
)}
/>
</>
);
};
return (
<div className={styles.root} data-testid="trace-details-thread">
<div className={styles.content}>
{!hasNoThread && interestedSpanStatus !== InterestedSpanStatus.NotFound && (
<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,80 @@
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.
interestedSpanId?: string;
clearInterestedSpan: () => 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 [interestedSpanId, setInterestedSpanId] = 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 clearInterestedSpan = useCallback((): void => {
setInterestedSpanId(undefined);
setSpanIdParam(undefined);
}, [setSpanIdParam]);
return useMemo(
() => ({
interestedSpanId,
clearInterestedSpan,
selectedSpanId,
selectSpan,
panelState,
}),
[
interestedSpanId,
clearInterestedSpan,
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,103 @@
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;
const STALE_TIME_MS = 5 * 60 * 1000;
type PageParam = Pick<GetTraceThreadParams, 'after' | 'before'>;
interface UseTraceThreadOptions {
traceId: string;
interestedSpanId?: string;
}
interface UseTraceThreadResult {
spans: ThreadSpan[];
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
fetchNextPage: () => Promise<unknown>;
fetchPreviousPage: () => Promise<unknown>;
}
export function useTraceThread({
traceId,
interestedSpanId,
}: UseTraceThreadOptions): UseTraceThreadResult {
const query = useInfiniteQuery({
queryKey: getGetTraceThreadQueryKey(
{ traceID: traceId },
{ spanId: interestedSpanId },
),
queryFn: ({ pageParam, signal }) => {
const { after, before } = (pageParam ?? {}) as PageParam;
return getTraceThread(
{ traceID: traceId },
{
limit: THREAD_PAGE_LIMIT,
after,
before,
// The interested span only shapes the first page; cursors drive the rest.
spanId: after || before ? undefined : interestedSpanId,
},
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 interested span is a 404; retrying won't find it.
retry: (failureCount, error): boolean =>
!isNotFoundError(error) && failureCount < MAX_RETRIES,
enabled: !!traceId,
refetchOnWindowFocus: false,
staleTime: STALE_TIME_MS,
});
const spans = useMemo(
() =>
query.data?.pages.flatMap((page) => page.data.spans.map(toThreadSpan)) ?? [],
[query.data],
);
const spansAboveFirstPage = useMemo(
() =>
query.data?.pages.reduce(
(count, page, index) =>
(query.data.pageParams[index] as PageParam | undefined)?.before
? count + page.data.spans.length
: count,
0,
) ?? 0,
[query.data],
);
return {
spans,
spansAboveFirstPage,
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,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

@@ -0,0 +1,22 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { ThreadMessage } from '../AIThreadMessage/types';
export type ThreadSpan = SpanV3 & {
formattedInput?: ThreadMessage[];
formattedOutput?: ThreadMessage[];
};
export enum ThreadView {
Formatted = 'formatted',
Json = 'json',
}
export enum InterestedSpanStatus {
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,46 @@
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { isAxiosError } from 'axios';
import { collectToolCallKeys } from '../AIThreadMessage/utils';
import { InterestedSpanStatus, ThreadSpan } from './types';
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
export function toThreadSpan(span: SpantypesThreadSpanDTO): ThreadSpan {
return {
...(span as unknown as ThreadSpan),
'service.name': span.resource?.['service.name'] || '',
timestamp: span.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) => [
...collectToolCallKeys(span.formattedInput ?? []),
...collectToolCallKeys(span.formattedOutput ?? []),
]),
);
}
export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
}
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (spans.length === 0) {
return InterestedSpanStatus.NotFound;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found
: InterestedSpanStatus.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

@@ -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

@@ -1,5 +1,5 @@
import { useCallback, useMemo, 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 { ToggleGroup } from '@signozhq/ui/toggle-group';
@@ -12,6 +12,7 @@ import { ENTITY_VERSION_V5 } 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 { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -163,12 +164,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 {
@@ -177,7 +177,7 @@ function Filters({
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
[filteredSpanIds, history],
);
const isFilterActive = committedExpression.trim().length > 0;

View File

@@ -25,7 +25,7 @@ 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,
@@ -676,19 +676,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)

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(
@@ -441,4 +445,41 @@ describe('Span Click User Flows', () => {
{ replace: true },
);
});
it('keeps a tab param the router has not seen when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// nuqs wrote ?tab=thread to the real URL; react-router's copy is stale.
__setSearchParamsGetterForTest(() => new URLSearchParams('tab=thread'));
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
isFullDataLoaded={false}
localUncollapsedNodes={new Set()}
setLocalUncollapsedNodes={jest.fn()}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
filteredSpanIds={[]}
isFilterActive={false}
/>,
undefined,
{ initialRoute: '/trace' },
);
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
expect(mockSafeNavigate).toHaveBeenCalledWith(
{ search: expect.stringMatching(/tab=thread.*spanId=span-1/) },
{ replace: true },
);
});
});

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,90 +1,19 @@
/* 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, useRef, 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 {
SPAN_DETAILS_PANEL_MIN_WIDTH,
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 dataLoadedFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -94,421 +23,31 @@ 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 = SPAN_DETAILS_PANEL_MIN_WIDTH;
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}
dataLoadedFiredForRef={dataLoadedFiredForRef}
/>
{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

@@ -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

@@ -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

View File

@@ -11,6 +11,8 @@ export const GEN_AI_KEYS = {
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
inputMessages: 'gen_ai.input.messages',
outputMessages: 'gen_ai.output.messages',
} as const;
export enum AiSpanKind {

View File

@@ -0,0 +1,49 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from './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);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// Spans from before the JSON column have no formatted messages.
return {
formattedInput: span.formattedInput ?? [],
formattedOutput: span.formattedOutput ?? [],
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasRawMessages(data: SpanMessagesData): boolean {
return !!data.rawInput || !!data.rawOutput;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || hasRawMessages(data);
}