mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-07 18:50:41 +01:00
Compare commits
8 Commits
feat/trace
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ce47351a | ||
|
|
56cef582e4 | ||
|
|
67b9f42e7a | ||
|
|
8537a7688c | ||
|
|
6e8e6f3659 | ||
|
|
b317048220 | ||
|
|
5c9a8eb19f | ||
|
|
a54206ed5e |
@@ -32,6 +32,7 @@ export const REACT_QUERY_KEY = {
|
||||
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
|
||||
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
|
||||
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
|
||||
GET_TRACE_THREAD: 'GET_TRACE_THREAD',
|
||||
GET_POD_LIST: 'GET_POD_LIST',
|
||||
GET_NODE_LIST: 'GET_NODE_LIST',
|
||||
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(ol),
|
||||
:global(ul) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
}
|
||||
|
||||
.plainText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
|
||||
&.boxed {
|
||||
gap: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
.collapsibleToggle {
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collapsibleLabel {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mediaChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-round);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
isOutput?: boolean;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
isOutput,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, content, finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{content.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
content.map((part, index) => (
|
||||
<MessagePart
|
||||
// Parts have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' ? (
|
||||
<CollapsibleBlock label={roleLabel} testId="ai-message-system-toggle">
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{roleLabel && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel}
|
||||
{isOutput && (
|
||||
<Badge color={tone} testId="ai-message-output-badge">
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
isOutput: false,
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.collapsible, isBoxed && styles.boxed)}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapsibleToggle}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.collapsibleLabel}>{label}</span>
|
||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{isOpen && <div className={styles.collapsibleBody}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableText({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={styles.text}
|
||||
markdownContent={visibleText}
|
||||
variables={{}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
|
||||
)}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandableText.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.json}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MessagePart as MessagePartType } from '../TraceDetailsThread/types';
|
||||
import MediaPart from './parts/MediaPart';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element | null {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} isMarkdown />;
|
||||
case 'thinking':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id}
|
||||
name={part.name}
|
||||
args={part.arguments}
|
||||
server={part.server}
|
||||
/>
|
||||
);
|
||||
case 'tool_result':
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={part.toolCallId}
|
||||
name={part.name}
|
||||
content={part.content}
|
||||
isError={part.isError}
|
||||
isLinkEnabled={!!isToolLinkEnabled?.(part.toolCallId)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
case 'media':
|
||||
return (
|
||||
<MediaPart
|
||||
modality={part.modality}
|
||||
mimeType={part.mimeType}
|
||||
name={part.name}
|
||||
/>
|
||||
);
|
||||
case 'generic':
|
||||
return <TextContent text={part.content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.chip} data-testid={testId}>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as (markdown) text. */
|
||||
function TextContent({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextContent.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default TextContent;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
isToolLinkEnabled: (toolCallId: string) => boolean;
|
||||
onToolLinkClick: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for ids in `toolCallIds`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallIds: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const isToolLinkEnabled = useCallback(
|
||||
(toolCallId: string): boolean => toolCallIds.has(toolCallId),
|
||||
[toolCallIds],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallId: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallId)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { isToolLinkEnabled, onToolLinkClick };
|
||||
}
|
||||
53
frontend/src/pages/TraceDetailsV3/AIThreadMessage/utils.ts
Normal file
53
frontend/src/pages/TraceDetailsV3/AIThreadMessage/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error';
|
||||
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason]) || 'success';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectToolCallIds(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.content.flatMap((part) =>
|
||||
part.type === 'tool_call' ? [part.id] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,23 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: string;
|
||||
tooltip: ReactNode;
|
||||
tooltipClassName?: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function EntityMetadataItem({
|
||||
tooltip,
|
||||
tooltipClassName,
|
||||
icon,
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={tooltip}>
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: tooltipClassName }}
|
||||
>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
@@ -27,6 +32,7 @@ function EntityMetadataItem({
|
||||
|
||||
EntityMetadataItem.defaultProps = {
|
||||
icon: null,
|
||||
tooltipClassName: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataItem;
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import {
|
||||
CalendarClock,
|
||||
CircleDollarSign,
|
||||
Coins,
|
||||
Server,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import { formatCost, formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface EntityMetadataRowProps {
|
||||
@@ -16,6 +26,8 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: TraceTokenUsage;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -32,6 +44,8 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const durationTooltip =
|
||||
@@ -91,6 +105,25 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
tooltipClassName={tooltipStyles.tooltipContent}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(tokens.input)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{cost != null && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<CircleDollarSign size={ICON_SIZE} />}
|
||||
>
|
||||
{formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +135,8 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { formatTokens } from '../SpanHoverCard/aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: TraceTokenUsage;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={`${tooltipStyles.body} ${styles.tokenTooltip}`}>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Tokens" value={formatTokens(input + output)} isTotal />
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input" value={formatTokens(input)} />
|
||||
<TooltipRow label="Output" value={formatTokens(output)} />
|
||||
{cacheRead !== undefined && (
|
||||
<TooltipRow label="Cache read" value={formatTokens(cacheRead)} />
|
||||
)}
|
||||
{cacheWrite !== undefined && (
|
||||
<TooltipRow label="Cache write" value={formatTokens(cacheWrite)} />
|
||||
)}
|
||||
{reasoning !== undefined && (
|
||||
<TooltipRow label="Reasoning" value={formatTokens(reasoning)} />
|
||||
)}
|
||||
</TooltipSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -0,0 +1,25 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import SpanMessages from '../../SpanMessages/SpanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasAnyMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../SpanMessages/utils';
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const canFormat = hasFormattedMessages(data);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallIds = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
|
||||
scrollRef,
|
||||
toolCallIds,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="span-ai-messages">
|
||||
<div className={styles.header}>
|
||||
Messages
|
||||
{canFormat && (
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId="span-ai-messages-view-toggle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.scroll} ref={scrollRef}>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={effectiveView}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -52,6 +52,7 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -320,6 +321,7 @@ function SpanDetailsContent({
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<SpanAIMessages span={selectedSpan} />
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getSpanAiDetails, getSpanTokenUsage } from '../SpanHoverCard/aiUsage';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -31,6 +33,11 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const tokens = useMemo(() => getSpanTokenUsage(selectedSpan), [selectedSpan]);
|
||||
const cost = useMemo(
|
||||
() => getSpanAiDetails(selectedSpan)?.usage?.cost,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -63,6 +70,8 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={tokens}
|
||||
cost={cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/genAi';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanHintBadge from '../SpanHintBadge';
|
||||
|
||||
const spanWith = (
|
||||
attributes: Record<string, string | number>,
|
||||
responseStatusCode = '',
|
||||
): SpanV3 =>
|
||||
({
|
||||
attributes,
|
||||
resource: {},
|
||||
response_status_code: responseStatusCode,
|
||||
}) as unknown as SpanV3;
|
||||
|
||||
describe('SpanHintBadge', () => {
|
||||
it.each([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, 'LLM'],
|
||||
[{ 'gen_ai.tool.name': 'search' }, 'TOOL'],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, 'AGENT'],
|
||||
])('shows only the AI badge for %p', (attributes, label) => {
|
||||
render(<SpanHintBadge span={spanWith(attributes, '200')} />);
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.queryByText('200')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the HTTP status code for non-AI spans', () => {
|
||||
render(<SpanHintBadge span={spanWith({}, '503')} />);
|
||||
|
||||
expect(screen.getByText('503')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing for a non-AI span without a status code', () => {
|
||||
const { container } = render(<SpanHintBadge span={spanWith({})} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
10
frontend/src/pages/TraceDetailsV3/SpanHintBadge/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AiSpanKind } from '../utils/genAi';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -12,37 +12,16 @@
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -12,6 +14,12 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getSpanAiDetails, SpanAiDetails } from './aiUsage';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -38,6 +46,7 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -47,33 +56,46 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
<div className={cx(tooltipStyles.body, styles.content)}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={tooltipStyles.title} style={{ color }}>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="medium">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TooltipSection>
|
||||
{ai?.model && <TooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TooltipRow label="agent" value={ai.agentName} />}
|
||||
<TooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TooltipRow label="start" value={`${toFixed(relativeStartMs, 2)} ms`} />
|
||||
<TooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
<TooltipSection>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
<TooltipRow
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TooltipSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -137,6 +159,7 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getSpanAiDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
@@ -166,7 +189,7 @@ export function SpanHoverCard({
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className={styles.popover}
|
||||
className={cx(tooltipStyles.tooltipContent, styles.popover)}
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getUsageTotals,
|
||||
SpanAiUsage,
|
||||
} from './aiUsage';
|
||||
import TooltipRow from '../TraceTooltip/TooltipRow';
|
||||
import TooltipSection from '../TraceTooltip/TooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const { outputTokens = 0, cacheReadTokens, cacheCreationTokens, cost } = usage;
|
||||
const { inputUsage, totalUsage } = getUsageTotals(usage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Input usage" value={formatTokens(inputUsage)} isTotal />
|
||||
{cacheReadTokens !== undefined && (
|
||||
<TooltipRow label="cache read" value={formatTokens(cacheReadTokens)} />
|
||||
)}
|
||||
{cacheCreationTokens !== undefined && (
|
||||
<TooltipRow
|
||||
label="cache creation"
|
||||
value={formatTokens(cacheCreationTokens)}
|
||||
/>
|
||||
)}
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow
|
||||
label="Output usage"
|
||||
value={formatTokens(outputTokens)}
|
||||
isTotal
|
||||
/>
|
||||
</TooltipSection>
|
||||
<TooltipSection>
|
||||
<TooltipRow label="Total usage" value={formatTokens(totalUsage)} isTotal />
|
||||
{cost !== undefined && <TooltipRow label="cost" value={formatCost(cost)} />}
|
||||
</TooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -0,0 +1,144 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getSpanAiDetails, getUsageTotals } from '../aiUsage';
|
||||
import { SpanTooltipContent } from '../SpanHoverCard';
|
||||
|
||||
const baseProps = {
|
||||
spanName: 'chat gpt-4o',
|
||||
color: '#fff',
|
||||
hasError: false,
|
||||
relativeStartMs: 268,
|
||||
durationMs: 1110,
|
||||
};
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
const usageAttributes = {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 200,
|
||||
'gen_ai.usage.cache_read.input_tokens': 600,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 100,
|
||||
};
|
||||
|
||||
describe('getSpanAiDetails', () => {
|
||||
it('returns undefined for a span without gen_ai attributes', () => {
|
||||
expect(getSpanAiDetails(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads model, tool, agent, token usage and cost', () => {
|
||||
expect(
|
||||
getSpanAiDetails(
|
||||
spanWith({
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.tool.name': 'search',
|
||||
'gen_ai.agent.name': 'planner',
|
||||
'gen_ai.usage.input_tokens': '120',
|
||||
'gen_ai.usage.output_tokens': 30,
|
||||
'signoz.gen_ai.usage.tokens.cost': '0.00000056',
|
||||
}),
|
||||
),
|
||||
).toStrictEqual({
|
||||
model: 'gpt-4o',
|
||||
toolName: 'search',
|
||||
agentName: 'planner',
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
cacheReadTokens: undefined,
|
||||
cacheCreationTokens: undefined,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each<Record<string, string>>([
|
||||
{ 'gen_ai.provider.name': 'Anthropic' },
|
||||
{ 'gen_ai.system': 'anthropic' },
|
||||
{ 'gen_ai.response.model': 'claude-sonnet-4' },
|
||||
{ 'gen_ai.request.model': 'anthropic.claude-v2' },
|
||||
])('detects Anthropic from %p', (attributes) => {
|
||||
expect(
|
||||
getSpanAiDetails(spanWith({ ...usageAttributes, ...attributes }))?.usage
|
||||
?.isAnthropic,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsageTotals', () => {
|
||||
it('adds cache tokens to input for Anthropic', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'anthropic' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1700,
|
||||
totalUsage: 1900,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats input as already including cache tokens for other providers', () => {
|
||||
const usage = getSpanAiDetails(
|
||||
spanWith({ ...usageAttributes, 'gen_ai.provider.name': 'openai' }),
|
||||
)?.usage;
|
||||
|
||||
expect(usage && getUsageTotals(usage)).toStrictEqual({
|
||||
inputUsage: 1000,
|
||||
totalUsage: 1200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpanTooltipContent', () => {
|
||||
it('renders status, start and duration for a non-AI span', () => {
|
||||
render(<SpanTooltipContent {...baseProps} />);
|
||||
|
||||
expect(screen.getByText('chat gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||
expect(screen.getByText('268 ms')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.11 s')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Usage Breakdown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the usage breakdown with cache rows and cost', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{
|
||||
model: 'gpt-4o',
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 600,
|
||||
cacheCreationTokens: 100,
|
||||
cost: 0.00000056,
|
||||
isAnthropic: false,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Usage Breakdown')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache read')).toBeInTheDocument();
|
||||
expect(screen.getByText('600')).toBeInTheDocument();
|
||||
expect(screen.getByText('cache creation')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,200')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.00000056')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides cache rows and cost when the attributes are absent', () => {
|
||||
render(
|
||||
<SpanTooltipContent
|
||||
{...baseProps}
|
||||
ai={{ usage: { inputTokens: 10, outputTokens: 5, isAnthropic: false } }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('cache read')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cache creation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('cost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
112
frontend/src/pages/TraceDetailsV3/SpanHoverCard/aiUsage.ts
Normal file
112
frontend/src/pages/TraceDetailsV3/SpanHoverCard/aiUsage.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceTokenUsage } from '../TraceDetailsHeader/types';
|
||||
import { GEN_AI_KEYS } from '../utils/genAi';
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
cost?: number;
|
||||
isAnthropic: boolean;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getNumber(span: SpanV3, key: string): number | undefined {
|
||||
const value = getSpanAttribute(span, key);
|
||||
if (value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function isAnthropicSpan(span: SpanV3): boolean {
|
||||
const provider = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.providerName) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.system) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
const model = (
|
||||
getSpanAttribute(span, GEN_AI_KEYS.responseModel) ??
|
||||
getSpanAttribute(span, GEN_AI_KEYS.requestModel) ??
|
||||
''
|
||||
).toLowerCase();
|
||||
|
||||
return (
|
||||
provider === 'anthropic' ||
|
||||
model.includes('claude') ||
|
||||
model.includes('anthropic')
|
||||
);
|
||||
}
|
||||
|
||||
export function getSpanAiDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getNumber(span, GEN_AI_KEYS.cacheCreationTokens),
|
||||
cost: getNumber(span, GEN_AI_KEYS.cost),
|
||||
isAnthropic: isAnthropicSpan(span),
|
||||
};
|
||||
const hasUsage =
|
||||
usage.inputTokens !== undefined || usage.outputTokens !== undefined;
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getSpanAttribute(span, GEN_AI_KEYS.requestModel) || undefined,
|
||||
toolName: getSpanAttribute(span, GEN_AI_KEYS.toolName) || undefined,
|
||||
agentName: getSpanAttribute(span, GEN_AI_KEYS.agentName) || undefined,
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic reports input_tokens without cached tokens; every other provider
|
||||
* (OpenAI, Azure OpenAI, Gemini, Mistral, DeepSeek, Groq, xAI) counts cached
|
||||
* tokens inside input_tokens.
|
||||
*/
|
||||
export function getUsageTotals(usage: SpanAiUsage): {
|
||||
inputUsage: number;
|
||||
totalUsage: number;
|
||||
} {
|
||||
const input = usage.inputTokens ?? 0;
|
||||
const inputUsage = usage.isAnthropic
|
||||
? input + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0)
|
||||
: input;
|
||||
|
||||
return { inputUsage, totalUsage: inputUsage + (usage.outputTokens ?? 0) };
|
||||
}
|
||||
|
||||
export function formatTokens(value: number): string {
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
|
||||
export function getSpanTokenUsage(span: SpanV3): TraceTokenUsage | undefined {
|
||||
const usage = getSpanAiDetails(span)?.usage;
|
||||
if (!usage) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
input: usage.inputTokens ?? 0,
|
||||
output: usage.outputTokens ?? 0,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: getNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import AIThreadMessage, {
|
||||
AIThreadMessageProps,
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage, ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
type MessageProps = Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'isToolLinkEnabled' | 'onToolLinkClick'
|
||||
>;
|
||||
|
||||
interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
// Messages have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
message={message}
|
||||
isOutput={isOutput}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
@@ -0,0 +1,21 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} />;
|
||||
}
|
||||
return (
|
||||
<pre className={styles.raw} data-testid="span-raw-messages">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -0,0 +1,49 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l1-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.missing {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import { getSpanMessages, hasAnyMessages } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface SpanMessagesProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'isToolLinkEnabled' | 'onToolLinkClick'
|
||||
> {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Input and output messages of one AI span, shared by Thread and Span Details. */
|
||||
function SpanMessages({
|
||||
span,
|
||||
view,
|
||||
className,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
const callbacks = { isToolLinkEnabled, onToolLinkClick };
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return (
|
||||
<div className={styles.missing}>
|
||||
This span has no input or output messages.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, className)}>
|
||||
<MessagesSection
|
||||
title={isFormatted ? undefined : 'Input'}
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-messages-input"
|
||||
{...callbacks}
|
||||
/>
|
||||
<MessagesSection
|
||||
title={isFormatted ? undefined : 'Output'}
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-messages-output"
|
||||
{...callbacks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpanMessages.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default SpanMessages;
|
||||
40
frontend/src/pages/TraceDetailsV3/SpanMessages/utils.ts
Normal file
40
frontend/src/pages/TraceDetailsV3/SpanMessages/utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { parseJsonObject } from '../AIThreadMessage/utils';
|
||||
import { ThreadMessage, ThreadSpan } from '../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../utils/genAi';
|
||||
|
||||
/** 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 {
|
||||
return {
|
||||
formattedInput: span.formatted_input ?? [],
|
||||
formattedOutput: span.formatted_output ?? [],
|
||||
rawInput: toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]),
|
||||
rawOutput: toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFormattedMessages(data: SpanMessagesData): boolean {
|
||||
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
|
||||
}
|
||||
|
||||
export function hasAnyMessages(data: SpanMessagesData): boolean {
|
||||
return hasFormattedMessages(data) || !!data.rawInput || !!data.rawOutput;
|
||||
}
|
||||
@@ -34,6 +34,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: unset;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -18,40 +18,26 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
@@ -73,16 +59,22 @@ function DetailsLoader(): JSX.Element {
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const { data: summary, isLoading } = useTraceSummary();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
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);
|
||||
|
||||
@@ -116,9 +108,13 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
: 0;
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: summary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: summary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (summary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (summary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -140,9 +136,10 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
{isOverview && onFilteredSpansChange && summary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -171,9 +168,9 @@ function TraceDetailsHeader({
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
totalSpansCount={summary.totalSpansCount}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -183,9 +180,9 @@ function TraceDetailsHeader({
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
traceID={traceID || ''}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
@@ -196,28 +193,29 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceMetadata ? (
|
||||
{isLoading || !summary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceMetadata.rootServiceName,
|
||||
entryPoint: traceMetadata.rootServiceEntryPoint,
|
||||
name: summary.rootServiceName,
|
||||
entryPoint: summary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
|
||||
durationMs={summary.endTimestampMillis - summary.startTimestampMillis}
|
||||
timestamp={dayjs(summary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceMetadata.rootSpanStatusCode}
|
||||
tokens={summary.ai?.tokens}
|
||||
cost={summary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{isOverview && summary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
className={styles.tabs}
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Overview}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-overview"
|
||||
>
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Thread}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-thread"
|
||||
>
|
||||
Thread
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,9 +1,12 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { MOCK_TRACE_SUMMARY } from '../mockTraceSummary';
|
||||
import * as traceSummaryModule from '../useTraceSummary';
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
@@ -50,15 +53,9 @@ jest.mock('components/FieldsSelector', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
onFilteredSpansChange: jest.fn(),
|
||||
isDataLoaded: false,
|
||||
};
|
||||
const baseProps = { onFilteredSpansChange: jest.fn() };
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – back button', () => {
|
||||
beforeEach(() => {
|
||||
@@ -94,8 +91,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it('does not render the action buttons while data is still loading', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
|
||||
it('does not render the action buttons without a filter handler', () => {
|
||||
render(<TraceDetailsHeader />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
@@ -105,8 +102,8 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Analytics and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
it('renders Analytics and Settings action buttons', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
@@ -117,7 +114,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
@@ -133,68 +130,125 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
// Plain prop, no API mock needed: traceMetadata is passed straight in.
|
||||
const traceMetadata = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
|
||||
rootServiceName: 'inventory-frontend',
|
||||
rootServiceEntryPoint: 'large-trace-root',
|
||||
rootSpanStatusCode: '404',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
};
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the metadata (service, entry point, duration, status) when provided', () => {
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
it('shows skeletons instead of the metadata while the summary loads', () => {
|
||||
jest
|
||||
.spyOn(traceSummaryModule, 'useTraceSummary')
|
||||
.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const { container } = render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
|
||||
expect(screen.getByText('404')).toBeInTheDocument();
|
||||
// Duration goes through the shared formatter (e.g. "2 min").
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders the summary metadata with tokens and cost', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
const { startTimestampMillis, endTimestampMillis } = MOCK_TRACE_SUMMARY;
|
||||
const duration = getYAxisFormattedValue(
|
||||
`${traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis}`,
|
||||
`${endTimestampMillis - startTimestampMillis}`,
|
||||
'ms',
|
||||
);
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('$ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
render(<TraceDetailsHeader {...baseProps} />);
|
||||
|
||||
// Visible by default (showTraceDetails defaults to true).
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
|
||||
// Hide it.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
// Show it again.
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /show trace details/i }),
|
||||
);
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the metadata row when traceMetadata is absent', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary feeds the header.
|
||||
export const MOCK_TRACE_SUMMARY: TraceSummary = {
|
||||
startTimestampMillis: 1789627365929,
|
||||
endTimestampMillis: 1789627367867,
|
||||
rootServiceName: '',
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
hasMissingSpans: true,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 8000,
|
||||
cacheWrite: 1200,
|
||||
reasoning: 900,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface TraceTokenUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
/** Response of GET /api/v1/traces/{id}/summary. */
|
||||
export interface TraceSummary {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
totalSpansCount: number;
|
||||
totalErrorSpansCount: number;
|
||||
hasMissingSpans: boolean;
|
||||
ai?: {
|
||||
tokens?: TraceTokenUsage;
|
||||
// null when no span has a cost attribute
|
||||
totalCost: number | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MOCK_TRACE_SUMMARY } from './mockTraceSummary';
|
||||
import { TraceSummary } from './types';
|
||||
|
||||
interface UseTraceSummaryResult {
|
||||
data: TraceSummary | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Placeholder until GET /api/v1/traces/{id}/summary is available.
|
||||
export function useTraceSummary(): UseTraceSummaryResult {
|
||||
return { data: MOCK_TRACE_SUMMARY, isLoading: false };
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -0,0 +1,500 @@
|
||||
/* 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 { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './ThreadEmptyState.module.scss';
|
||||
|
||||
interface ThreadEmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function ThreadEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
testId,
|
||||
}: ThreadEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid={testId}>
|
||||
<span className={styles.icon}>{icon}</span>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.description}>{description}</span>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ThreadEmptyState.defaultProps = {
|
||||
actions: undefined,
|
||||
};
|
||||
|
||||
export default ThreadEmptyState;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadPageLoaderProps {
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.pageLoader} data-testid={testId}>
|
||||
<Skeleton active title={{ width: '30%' }} paragraph={{ rows: 2 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadPageLoader;
|
||||
@@ -0,0 +1,16 @@
|
||||
.card {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import SpanMessages from '../SpanMessages/SpanMessages';
|
||||
import { ThreadSpan, ThreadView } from './types';
|
||||
|
||||
import styles from './ThreadSpanCard.module.scss';
|
||||
|
||||
interface ThreadSpanCardProps {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
isSelected: boolean;
|
||||
onSelect: (span: ThreadSpan) => void;
|
||||
isToolLinkEnabled: (toolCallId: string) => boolean;
|
||||
onToolLinkClick: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function ThreadSpanCard({
|
||||
span,
|
||||
view,
|
||||
isSelected,
|
||||
onSelect,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: ThreadSpanCardProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<article
|
||||
className={cx(styles.card, isSelected && styles.isSelected)}
|
||||
onClick={(): void => onSelect(span)}
|
||||
data-span-id={span.span_id}
|
||||
data-testid="thread-span-card"
|
||||
>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={view}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ThreadSpanCard);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
|
||||
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;
|
||||
|
||||
interface ThreadSpanDetailsProps {
|
||||
panelState: DetailsPanelState;
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
function ThreadSpanDetails({
|
||||
panelState,
|
||||
span,
|
||||
}: ThreadSpanDetailsProps): JSX.Element {
|
||||
const { data: summary } = useTraceSummary();
|
||||
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
return (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={width}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={span}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
traceStartTime={summary?.startTimestampMillis}
|
||||
traceEndTime={summary?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanDetails;
|
||||
@@ -0,0 +1,23 @@
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
// Prepend compensation is manual; native anchoring would double-shift.
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
flex-shrink: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.pageLoader {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
|
||||
import { useThreadInfiniteScroll } from './hooks/useThreadInfiniteScroll';
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadSpanListProps {
|
||||
listRef: RefObject<HTMLDivElement>;
|
||||
firstItemKey?: string;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ThreadSpanList({
|
||||
listRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
children,
|
||||
}: ThreadSpanListProps): JSX.Element {
|
||||
const { topSentinelRef, bottomSentinelRef } = useThreadInfiniteScroll({
|
||||
containerRef: listRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.list} ref={listRef} data-testid="thread-span-list">
|
||||
<div ref={topSentinelRef} className={styles.sentinel} />
|
||||
{isFetchingPreviousPage && (
|
||||
<ThreadPageLoader testId="thread-loading-previous" />
|
||||
)}
|
||||
{children}
|
||||
{isFetchingNextPage && <ThreadPageLoader testId="thread-loading-next" />}
|
||||
<div ref={bottomSentinelRef} className={styles.sentinel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanList;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
|
||||
|
||||
import ThreadEmptyState from './ThreadEmptyState';
|
||||
|
||||
interface ThreadSpanNotFoundProps {
|
||||
onGoBack: () => void;
|
||||
onShowThread: () => void;
|
||||
}
|
||||
|
||||
function ThreadSpanNotFound({
|
||||
onGoBack,
|
||||
onShowThread,
|
||||
}: ThreadSpanNotFoundProps): JSX.Element {
|
||||
return (
|
||||
<ThreadEmptyState
|
||||
icon={<SearchX size={32} />}
|
||||
title="Span not found in this thread"
|
||||
description="The requested span isn't part of this trace's AI thread."
|
||||
testId="thread-span-not-found"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onGoBack}
|
||||
testId="thread-span-not-found-go-back"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<MessagesSquare size={14} />}
|
||||
onClick={onShowThread}
|
||||
testId="thread-span-not-found-show-thread"
|
||||
>
|
||||
Show thread
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanNotFound;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { ThreadView } from './types';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ label: 'Formatted', value: ThreadView.Formatted },
|
||||
{ label: 'JSON', value: ThreadView.Json },
|
||||
];
|
||||
|
||||
interface ThreadViewToggleProps {
|
||||
value: ThreadView;
|
||||
onChange: (view: ThreadView) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThreadViewToggle({
|
||||
value,
|
||||
onChange,
|
||||
testId,
|
||||
}: ThreadViewToggleProps): JSX.Element {
|
||||
const handleChange = (next: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (next) {
|
||||
onChange(next as ThreadView);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
items={VIEW_OPTIONS}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ThreadViewToggle.defaultProps = {
|
||||
testId: 'thread-view-toggle',
|
||||
};
|
||||
|
||||
export default ThreadViewToggle;
|
||||
@@ -0,0 +1,44 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
// Leaves room on the right for the span details dock.
|
||||
flex: 0 1 70%;
|
||||
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);
|
||||
}
|
||||
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { MessagesSquare } from '@signozhq/icons';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useScrollToAnchorSpan } from './hooks/useScrollToAnchorSpan';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
import ThreadEmptyState from './ThreadEmptyState';
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
import ThreadSpanCard from './ThreadSpanCard';
|
||||
import ThreadSpanDetails from './ThreadSpanDetails';
|
||||
import ThreadSpanList from './ThreadSpanList';
|
||||
import ThreadSpanNotFound from './ThreadSpanNotFound';
|
||||
import ThreadViewToggle from './ThreadViewToggle';
|
||||
import { AnchorStatus } from './types';
|
||||
import { buildToolCallIndex, getAnchorStatus } from './utils';
|
||||
|
||||
import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const INITIAL_LOADER_COUNT = 3;
|
||||
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
const [view, setView] = useThreadView();
|
||||
const { anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState } =
|
||||
useThreadSpanSelection();
|
||||
const thread = useTraceThread(traceId || '', anchorSpanId);
|
||||
const { spans, isLoading, isError } = thread;
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallIds = useMemo(() => buildToolCallIndex(spans), [spans]);
|
||||
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
|
||||
listRef,
|
||||
toolCallIds,
|
||||
);
|
||||
|
||||
const anchorStatus = getAnchorStatus(anchorSpanId, spans, isLoading);
|
||||
useScrollToAnchorSpan(
|
||||
listRef,
|
||||
anchorSpanId,
|
||||
anchorStatus === AnchorStatus.Found,
|
||||
);
|
||||
|
||||
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
|
||||
|
||||
const renderBody = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.initialLoader} data-testid="thread-loading">
|
||||
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ThreadPageLoader key={i} testId="thread-loading-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (anchorStatus === AnchorStatus.NotFound) {
|
||||
return (
|
||||
<ThreadSpanNotFound
|
||||
onGoBack={(): void => {
|
||||
void setTab(TraceDetailsTab.Overview);
|
||||
}}
|
||||
onShowThread={clearAnchor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError || spans.length === 0) {
|
||||
return (
|
||||
<ThreadEmptyState
|
||||
icon={<MessagesSquare size={32} />}
|
||||
title="No AI thread for this trace"
|
||||
description="Threads show the input and output messages of LLM spans. This trace has none."
|
||||
testId="thread-empty"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{anchorStatus === AnchorStatus.NoMessages && (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
className={styles.notice}
|
||||
title="The requested span has no input or output messages. Showing the rest of the thread."
|
||||
testId="thread-anchor-no-messages"
|
||||
/>
|
||||
)}
|
||||
<ThreadSpanList
|
||||
listRef={listRef}
|
||||
firstItemKey={spans[0]?.span_id}
|
||||
hasNextPage={thread.hasNextPage}
|
||||
hasPreviousPage={thread.hasPreviousPage}
|
||||
isFetchingNextPage={thread.isFetchingNextPage}
|
||||
isFetchingPreviousPage={thread.isFetchingPreviousPage}
|
||||
fetchNextPage={thread.fetchNextPage}
|
||||
fetchPreviousPage={thread.fetchPreviousPage}
|
||||
>
|
||||
{spans.map((span) => (
|
||||
<ThreadSpanCard
|
||||
key={span.span_id}
|
||||
span={span}
|
||||
view={view}
|
||||
isSelected={span.span_id === selectedSpanId}
|
||||
onSelect={selectSpan}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</ThreadSpanList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="trace-details-thread">
|
||||
<div className={styles.content}>
|
||||
<div className={styles.toolbar}>
|
||||
<ThreadViewToggle
|
||||
value={view}
|
||||
onChange={(next): void => {
|
||||
void setView(next);
|
||||
}}
|
||||
/>
|
||||
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
|
||||
</div>
|
||||
{renderBody()}
|
||||
</div>
|
||||
{panelState.isOpen && selectedSpan && (
|
||||
<ThreadSpanDetails panelState={panelState} span={selectedSpan} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MOCK_TRACE_THREAD_SPANS } from './mockThread';
|
||||
import { ThreadSpan, TraceThreadResponse } from './types';
|
||||
|
||||
export interface TraceThreadParams {
|
||||
traceId: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
before?: string;
|
||||
spanId?: string;
|
||||
}
|
||||
|
||||
// Mock-only knobs; the real endpoint pages by `limit`.
|
||||
const MOCK_PAGE_SIZE = 5;
|
||||
const MOCK_LATENCY_MS = 400;
|
||||
const SPANS_BEFORE_ANCHOR = 2;
|
||||
|
||||
type WireSpan = Omit<ThreadSpan, 'timestamp' | 'service.name'> & {
|
||||
time_unix: number;
|
||||
};
|
||||
|
||||
function toThreadSpan(span: WireSpan): ThreadSpan {
|
||||
return {
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
};
|
||||
}
|
||||
|
||||
function getStartIndex(
|
||||
spans: WireSpan[],
|
||||
{ after, before, spanId }: TraceThreadParams,
|
||||
): number {
|
||||
if (after) {
|
||||
return Number(after);
|
||||
}
|
||||
if (before) {
|
||||
return Math.max(0, Number(before) - MOCK_PAGE_SIZE);
|
||||
}
|
||||
if (spanId) {
|
||||
const index = spans.findIndex((s) => s.span_id === spanId);
|
||||
return index === -1 ? -1 : Math.max(0, index - SPANS_BEFORE_ANCHOR);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Placeholder for GET /api/v1/traces/{id}/thread; an unknown `spanId` yields no spans. */
|
||||
export async function getTraceThread(
|
||||
params: TraceThreadParams,
|
||||
): Promise<TraceThreadResponse> {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, MOCK_LATENCY_MS);
|
||||
});
|
||||
|
||||
const all = MOCK_TRACE_THREAD_SPANS as unknown as WireSpan[];
|
||||
const start = getStartIndex(all, params);
|
||||
if (start === -1) {
|
||||
return { spans: [] };
|
||||
}
|
||||
|
||||
const end = params.before
|
||||
? Number(params.before)
|
||||
: Math.min(all.length, start + MOCK_PAGE_SIZE);
|
||||
|
||||
return {
|
||||
spans: all.slice(start, end).map(toThreadSpan),
|
||||
prevCursor: start > 0 ? String(start) : undefined,
|
||||
nextCursor: end < all.length ? String(end) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
const ROOT_MARGIN = '200px 0px';
|
||||
|
||||
/** Calls `onVisible` while enabled and the sentinel is within reach of the container's viewport. */
|
||||
export function useIntersectionSentinel(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
sentinelRef: RefObject<HTMLElement>,
|
||||
isEnabled: boolean,
|
||||
onVisible: () => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!isEnabled || !sentinel) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
onVisible();
|
||||
}
|
||||
},
|
||||
{ root: containerRef.current, rootMargin: ROOT_MARGIN },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return (): void => observer.disconnect();
|
||||
}, [containerRef, sentinelRef, isEnabled, onVisible]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
/** Scrolls the anchor span's card into view once, after it first renders. */
|
||||
export function useScrollToAnchorSpan(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
anchorSpanId: string | undefined,
|
||||
isAnchorLoaded: boolean,
|
||||
): void {
|
||||
const scrolledForRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!anchorSpanId ||
|
||||
!isAnchorLoaded ||
|
||||
scrolledForRef.current === anchorSpanId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const card = containerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-span-id="${CSS.escape(anchorSpanId)}"]`,
|
||||
);
|
||||
if (card) {
|
||||
scrolledForRef.current = anchorSpanId;
|
||||
card.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
}, [containerRef, anchorSpanId, isAnchorLoaded]);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { useIntersectionSentinel } from './useIntersectionSentinel';
|
||||
|
||||
interface UseThreadInfiniteScrollOptions {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
firstItemKey?: string;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface UseThreadInfiniteScrollResult {
|
||||
topSentinelRef: RefObject<HTMLDivElement>;
|
||||
bottomSentinelRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Loads pages as either list edge nears view, holding position on prepend. */
|
||||
export function useThreadInfiniteScroll({
|
||||
containerRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
}: UseThreadInfiniteScrollOptions): UseThreadInfiniteScrollResult {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const heightBeforePrependRef = useRef<number | null>(null);
|
||||
|
||||
const loadPreviousRef = useRef((): void => {});
|
||||
loadPreviousRef.current = (): void => {
|
||||
heightBeforePrependRef.current = containerRef.current?.scrollHeight ?? null;
|
||||
void fetchPreviousPage();
|
||||
};
|
||||
// Stable wrapper so the observer isn't recreated on every render.
|
||||
const stableLoadPrevious = useRef((): void =>
|
||||
loadPreviousRef.current(),
|
||||
).current;
|
||||
|
||||
useIntersectionSentinel(
|
||||
containerRef,
|
||||
bottomSentinelRef,
|
||||
hasNextPage && !isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
);
|
||||
useIntersectionSentinel(
|
||||
containerRef,
|
||||
topSentinelRef,
|
||||
hasPreviousPage && !isFetchingPreviousPage,
|
||||
stableLoadPrevious,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const previousHeight = heightBeforePrependRef.current;
|
||||
if (!container || previousHeight === null) {
|
||||
return;
|
||||
}
|
||||
heightBeforePrependRef.current = null;
|
||||
container.scrollTop += container.scrollHeight - previousHeight;
|
||||
}, [containerRef, firstItemKey]);
|
||||
|
||||
return { topSentinelRef, bottomSentinelRef };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 { ThreadSpan } from '../types';
|
||||
|
||||
const SPAN_ID_PARAM = 'spanId';
|
||||
|
||||
interface ThreadSpanSelection {
|
||||
// The spanId the thread was opened with; it shapes the first page fetched.
|
||||
anchorSpanId?: string;
|
||||
clearAnchor: () => void;
|
||||
selectedSpanId?: string;
|
||||
selectSpan: (span: ThreadSpan) => void;
|
||||
panelState: DetailsPanelState;
|
||||
}
|
||||
|
||||
export function useThreadSpanSelection(): ThreadSpanSelection {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const selectedSpanId = urlQuery.get(SPAN_ID_PARAM) || undefined;
|
||||
const [anchorSpanId, setAnchorSpanId] = useState(selectedSpanId);
|
||||
|
||||
const setSpanIdParam = useCallback(
|
||||
(spanId?: string): void => {
|
||||
if (spanId) {
|
||||
urlQuery.set(SPAN_ID_PARAM, spanId);
|
||||
} else {
|
||||
urlQuery.delete(SPAN_ID_PARAM);
|
||||
}
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
},
|
||||
[urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(): void => setSpanIdParam(undefined),
|
||||
[setSpanIdParam],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const { open: openPanel } = panelState;
|
||||
const selectSpan = useCallback(
|
||||
(span: ThreadSpan): void => {
|
||||
setSpanIdParam(span.span_id);
|
||||
openPanel();
|
||||
},
|
||||
[setSpanIdParam, openPanel],
|
||||
);
|
||||
|
||||
const clearAnchor = useCallback((): void => {
|
||||
setAnchorSpanId(undefined);
|
||||
setSpanIdParam(undefined);
|
||||
}, [setSpanIdParam]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
anchorSpanId,
|
||||
clearAnchor,
|
||||
selectedSpanId,
|
||||
selectSpan,
|
||||
panelState,
|
||||
}),
|
||||
[anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useInfiniteQuery } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
|
||||
import { getTraceThread } from '../getTraceThread';
|
||||
import { ThreadSpan, TraceThreadResponse } from '../types';
|
||||
|
||||
const THREAD_PAGE_LIMIT = 100;
|
||||
|
||||
interface PageParam {
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
|
||||
interface UseTraceThreadResult {
|
||||
spans: ThreadSpan[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function useTraceThread(
|
||||
traceId: string,
|
||||
anchorSpanId?: string,
|
||||
): UseTraceThreadResult {
|
||||
const query = useInfiniteQuery<TraceThreadResponse>({
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_THREAD, traceId, anchorSpanId],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const { after, before } = (pageParam ?? {}) as PageParam;
|
||||
return getTraceThread({
|
||||
traceId,
|
||||
limit: THREAD_PAGE_LIMIT,
|
||||
after,
|
||||
before,
|
||||
// The anchor only shapes the first page; cursors drive the rest.
|
||||
spanId: after || before ? undefined : anchorSpanId,
|
||||
});
|
||||
},
|
||||
getNextPageParam: (lastPage): PageParam | undefined =>
|
||||
lastPage.nextCursor ? { after: lastPage.nextCursor } : undefined,
|
||||
getPreviousPageParam: (firstPage): PageParam | undefined =>
|
||||
firstPage.prevCursor ? { before: firstPage.prevCursor } : undefined,
|
||||
enabled: !!traceId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() => query.data?.pages.flatMap((page) => page.spans) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
|
||||
return {
|
||||
spans,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isFetchingPreviousPage: query.isFetchingPreviousPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
fetchPreviousPage: query.fetchPreviousPage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
// Placeholder until GET /api/v1/traces/{id}/thread is available. Wire format: spans carry time_unix.
|
||||
|
||||
const TRACE_ID = '7a1f0c2d9e8b4a6f5c3d2e1f0a9b8c7d';
|
||||
|
||||
const IMAGE_DATA_URL =
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAEAAQADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL';
|
||||
|
||||
const OBSERVABILITY_PROMPT =
|
||||
"You are an observability assistant. Observability is understanding a system's internal state from the telemetry it emits: traces, metrics, and logs. For LLM and agent workloads these signals also carry the model used, input and output token counts, the cost those tokens imply, the tools an agent invoked, and whether each call succeeded or failed. Always answer concisely. ".repeat(
|
||||
18,
|
||||
);
|
||||
|
||||
const SPAN_DEFAULTS = {
|
||||
events: [],
|
||||
flags: 0,
|
||||
has_children: false,
|
||||
has_error: false,
|
||||
is_remote: '',
|
||||
level: 1,
|
||||
parent_span_id: '0000000000000r00',
|
||||
references: [],
|
||||
resource: { 'service.name': 'travel-agent' },
|
||||
sub_tree_node_count: 0,
|
||||
trace_id: TRACE_ID,
|
||||
trace_state: '',
|
||||
};
|
||||
|
||||
export const MOCK_TRACE_THREAD_SPANS = [
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"system","content":"You are a research assistant. Use the search-web and get-weather tools as needed."},{"role":"user","content":[{"type":"text","text":"Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_opFrOeFM5pF9PnXe6lWcl0ui","toolName":"searchWeb","args":{"query":"SigNoz AI observability documentation"}},{"type":"tool-call","toolCallId":"call_HxRE0JdcM3F32SCbIu3SGk3G","toolName":"getWeather","args":{"city":"Bengaluru"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_opFrOeFM5pF9PnXe6lWcl0ui","toolName":"searchWeb","result":{"query":"SigNoz AI observability documentation","results":[{"title":"SigNoz docs","snippet":"AI observability ..."},{"title":"OTel GenAI","snippet":"semantic conventions ..."}]}},{"type":"tool-result","toolCallId":"call_HxRE0JdcM3F32SCbIu3SGk3G","toolName":"getWeather","result":{"city":"Bengaluru","temperatureC":18,"summary":"Clear"}}]}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'I found some information on SigNoz AI observability documentation. Here are a couple of relevant links:\n\n1. **SigNoz docs** - This includes details about AI observability.\n2. **OTel GenAI** - This document discusses semantic conventions related to observability.\n\nAs for the weather in Bengaluru, it is currently clear with a temperature of 18°C.',
|
||||
'gen_ai.provider.name': 'openai.chat',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
'gen_ai.usage.input_tokens': 243,
|
||||
'gen_ai.usage.output_tokens': 77,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.0006075000000000001,
|
||||
'signoz.gn_ai.usage.output_tokens.cost': 0.0007700000000000001,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0013775,
|
||||
},
|
||||
duration_nano: 1770290125,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'You are a research assistant. Use the search-web and get-weather tools as needed.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Search for SigNoz AI observability docs, then tell me the weather in Bengaluru.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_opFrOeFM5pF9PnXe6lWcl0ui',
|
||||
name: 'searchWeb',
|
||||
arguments: { query: 'SigNoz AI observability documentation' },
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_HxRE0JdcM3F32SCbIu3SGk3G',
|
||||
name: 'getWeather',
|
||||
arguments: { city: 'Bengaluru' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content:
|
||||
'{"query":"SigNoz AI observability documentation","results":[{"snippet":"AI observability ...","title":"SigNoz docs"},{"snippet":"semantic conventions ...","title":"OTel GenAI"}]}',
|
||||
name: 'searchWeb',
|
||||
toolCallId: 'call_opFrOeFM5pF9PnXe6lWcl0ui',
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: '{"city":"Bengaluru","summary":"Clear","temperatureC":18}',
|
||||
name: 'getWeather',
|
||||
toolCallId: 'call_HxRE0JdcM3F32SCbIu3SGk3G',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'I found some information on SigNoz AI observability documentation. Here are a couple of relevant links:\n\n1. **SigNoz docs** - This includes details about AI observability.\n2. **OTel GenAI** - This document discusses semantic conventions related to observability.\n\nAs for the weather in Bengaluru, it is currently clear with a temperature of 18°C.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
kind_string: 'Internal',
|
||||
name: 'ai.generateText.doGenerate',
|
||||
span_id: '6cee8072961624a0',
|
||||
time_unix: 1789627365000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"content": "A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?", "role": "user"}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'{"id":"resp_017c5eb995d99968006a199f9b483481979ca790db905506fc","created_at":1780064155.0,"error":null,"instructions":"You solve problems step by step and give a final answer.","model":"o3-mini-2025-01-31","object":"response","output":[{"id":"rs_017c5eb995d99968006a199f9bff248197978bf9193f34c29a","summary":[],"type":"reasoning","content":null},{"id":"msg_017c5eb995d99968006a199f9c8dcc81979f4e10afbe5350a1","content":[{"annotations":[],"text":"Step 1: Let the cost of the ball be x dollars.\\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\\n x + (x + 1) = 1.10\\nStep 4: Simplify the equation:\\n 2x + 1 = 1.10\\nStep 5: Subtract 1 from both sides:\\n 2x = 0.10\\nStep 6: Divide both sides by 2:\\n x = 0.05\\n\\nFinal Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"status":"completed","usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0},"output_tokens":431,"output_tokens_details":{"reasoning_tokens":128},"total_tokens":480}}',
|
||||
'gen_ai.request.model': 'o3-mini-2025-01-31',
|
||||
'gen_ai.usage.cache_read.input_tokens': 0,
|
||||
'gen_ai.usage.input_tokens': 49,
|
||||
'gen_ai.usage.output_tokens': 431,
|
||||
'gen_ai.usage.reasoning.output_tokens': 128,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.00012250000000000002,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.0043100000000000005,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.004432500000000001,
|
||||
},
|
||||
duration_nano: 2927763968,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{ role: 'assistant', content: [{ type: 'thinking' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n 2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n 2x = 0.10\nStep 6: Divide both sides by 2:\n x = 0.05\n\nFinal Answer: The ball costs $0.05.',
|
||||
},
|
||||
],
|
||||
finishReason: 'stop',
|
||||
},
|
||||
],
|
||||
kind_string: 'Internal',
|
||||
name: 'response',
|
||||
span_id: '9d1af71602a60828',
|
||||
time_unix: 1789627367500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'{"messages":[{"role":"user","content":"A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?"}]}',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'{"completion":"Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\\n\\n x + (x + 1) = 1.10\\n\\nCombine like terms:\\n\\n 2x + 1 = 1.10\\n\\nSubtract 1 from both sides:\\n\\n 2x = 0.10\\n\\nDivide both sides by 2:\\n\\n x = 0.05\\n\\nSo, the ball costs 5 cents.","reasoning":"**Calculating ball price**\\n\\nI\'m determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.","rawRequest":{"model":"openai/o3-mini","max_completion_tokens":2000}}',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'openai/o3-mini',
|
||||
'gen_ai.usage.cache_read.input_tokens': 0,
|
||||
'gen_ai.usage.input_tokens': 33,
|
||||
'gen_ai.usage.output_tokens': 629,
|
||||
'gen_ai.usage.reasoning.output_tokens': 256,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.00008250000000000001,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.0062900000000000005,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0063725000000000006,
|
||||
},
|
||||
duration_nano: 4209000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'A bat and a ball cost $1.10. The bat costs $1 more than the ball. How much is the ball?',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
content:
|
||||
"**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.",
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n x + (x + 1) = 1.10\n\nCombine like terms:\n\n 2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n 2x = 0.10\n\nDivide both sides by 2:\n\n x = 0.05\n\nSo, the ball costs 5 cents.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'LLM Generation',
|
||||
span_id: 'af95cec99712ad26',
|
||||
time_unix: 1789627370000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role": "user", "parts": [{"type": "text", "content": "What\'s the weather in Bengaluru? Use the tool."}]}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
'gen_ai.usage.input_tokens': 66,
|
||||
'gen_ai.usage.output_tokens': 18,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.00016500000000000003,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00018,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00034500000000000004,
|
||||
},
|
||||
duration_nano: 1652955648,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content: "What's the weather in Bengaluru? Use the tool.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{ role: 'assistant', content: [], finishReason: 'tool_call' },
|
||||
],
|
||||
kind_string: 'Internal',
|
||||
name: 'litellm_request',
|
||||
span_id: 'adfbedf6477f7f24',
|
||||
time_unix: 1789627372500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'{"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'The current weather in Bengaluru is partly cloudy with a temperature of 28°C.',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
},
|
||||
duration_nano: 939838834,
|
||||
formatted_input: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'generic',
|
||||
content:
|
||||
'{"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'The current weather in Bengaluru is partly cloudy with a temperature of 28°C.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
kind_string: 'Server',
|
||||
name: '/openai/v1/chat/completions',
|
||||
span_id: '0ed3e2f860c741b9',
|
||||
time_unix: 1789627375000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages': `[{"role": "user", "parts": [{"type": "text", "text": "What animal is in this image? One sentence."}, {"type": "image_url", "image_url": {"url": "${IMAGE_DATA_URL}"}}]}]`,
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
},
|
||||
duration_nano: 412100096,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'What animal is in this image? One sentence.',
|
||||
},
|
||||
{
|
||||
type: 'generic',
|
||||
content: `{"image_url":{"url":"${IMAGE_DATA_URL}"},"type":"image_url"}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
has_error: true,
|
||||
kind_string: 'Internal',
|
||||
name: 'litellm_request',
|
||||
span_id: '61c74044c542ed88',
|
||||
status_code: 2,
|
||||
status_code_string: 'Error',
|
||||
time_unix: 1789627377500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role": "user", "parts": [{"type": "text", "content": "Stream a short haiku about telemetry."}]}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role": "unknown", "parts": [{"type": "text", "content": "Data whispers loud, \\nSignals traverse through the night, \\nTruth in every byte."}], "finish_reason": "stop"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
'gen_ai.usage.cache_read.input_tokens': 0,
|
||||
'gen_ai.usage.input_tokens': 15,
|
||||
'gen_ai.usage.output_tokens': 17,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.000037500000000000003,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00017,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00020750000000000003,
|
||||
},
|
||||
duration_nano: 1129929000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', content: 'Stream a short haiku about telemetry.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'unknown',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'Data whispers loud, \nSignals traverse through the night, \nTruth in every byte.',
|
||||
},
|
||||
],
|
||||
finishReason: 'stop',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'ChatOpenAI.chat',
|
||||
span_id: 'e9d08ec1ac3417d5',
|
||||
time_unix: 1789627380000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages': JSON.stringify([
|
||||
{ role: 'system', content: OBSERVABILITY_PROMPT },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'In one sentence, what is observability?' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
"Observability is the ability to understand a system's internal state through the telemetry it emits, such as traces, metrics, and logs.",
|
||||
'gen_ai.provider.name': 'openai.chat',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
'gen_ai.usage.input_tokens': 1371,
|
||||
'gen_ai.usage.output_tokens': 27,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.0034275000000000004,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00027,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0036975000000000003,
|
||||
},
|
||||
duration_nano: 959577625,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [{ type: 'text', content: OBSERVABILITY_PROMPT }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', content: 'In one sentence, what is observability?' },
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
"Observability is the ability to understand a system's internal state through the telemetry it emits, such as traces, metrics, and logs.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
kind_string: 'Internal',
|
||||
name: 'ai.generateText.doGenerate',
|
||||
span_id: '6bf32b44d3456393',
|
||||
time_unix: 1789627382500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":"List three things to pack for Paris in October."}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":"1. A light waterproof jacket\\n2. Comfortable walking shoes\\n3. A","finish_reason":"length"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.usage.input_tokens': 18,
|
||||
'gen_ai.usage.output_tokens': 16,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.000045,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00016,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00020500000000000002,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'List three things to pack for Paris in October.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content:
|
||||
'1. A light waterproof jacket\n2. Comfortable walking shoes\n3. A',
|
||||
},
|
||||
],
|
||||
finishReason: 'length',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat gpt-4o',
|
||||
span_id: '5e00000000000001',
|
||||
time_unix: 1789627385000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":"Write a fake 5-star review for my hotel."}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":null,"refusal":"I can\'t help write fake reviews.","finish_reason":"content_filter"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.usage.input_tokens': 15,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.000037500000000000003,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00009,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0001275,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', content: 'Write a fake 5-star review for my hotel.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', content: "I can't help write fake reviews." }],
|
||||
finishReason: 'content_filter',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat gpt-4o',
|
||||
span_id: '5e00000000000002',
|
||||
time_unix: 1789627387500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":[{"type":"text","text":"rate limited, retry after 30s"}],"is_error":true}]}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":[{"type":"text","text":"The booking API is rate limited, retrying."},{"type":"tool_use","id":"toolu_02","name":"book_hotel","input":{"city":"Paris","nights":2}}],"stop_reason":"tool_use"}]',
|
||||
'gen_ai.provider.name': 'anthropic',
|
||||
'gen_ai.request.model': 'claude-sonnet-4',
|
||||
'gen_ai.usage.input_tokens': 210,
|
||||
'gen_ai.usage.output_tokens': 48,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.0005250000000000001,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00048000000000000007,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.001005,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
content: 'rate limited, retry after 30s',
|
||||
toolCallId: 'toolu_01',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', content: 'The booking API is rate limited, retrying.' },
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'toolu_02',
|
||||
name: 'book_hotel',
|
||||
arguments: { city: 'Paris', nights: 2 },
|
||||
},
|
||||
],
|
||||
finishReason: 'tool_call',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat claude-sonnet-4',
|
||||
span_id: '5e00000000000003',
|
||||
time_unix: 1789627390000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"system","content":"Reply with JSON only."},{"role":"user","content":"Extract city and nights: two nights in Paris"}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":"{\\"city\\":\\"Paris\\",\\"nights\\":2}","finish_reason":"stop"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o-mini',
|
||||
'gen_ai.usage.input_tokens': 31,
|
||||
'gen_ai.usage.output_tokens': 11,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.0000775,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00011,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0001875,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'system',
|
||||
content: [{ type: 'text', content: 'Reply with JSON only.' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
content: 'Extract city and nights: two nights in Paris',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', content: '{"city":"Paris","nights":2}' }],
|
||||
finishReason: 'stop',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat gpt-4o-mini',
|
||||
span_id: '5e00000000000004',
|
||||
time_unix: 1789627392500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":"Weather in Paris and Tokyo?"}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":null,"tool_calls":[{"id":"call_a","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}},{"id":"call_b","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Tok"}}],"finish_reason":"tool_calls"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.usage.input_tokens': 40,
|
||||
'gen_ai.usage.output_tokens': 30,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.0001,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00030000000000000003,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.0004,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', content: 'Weather in Paris and Tokyo?' }],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_a',
|
||||
name: 'get_weather',
|
||||
arguments: { city: 'Paris' },
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
id: 'call_b',
|
||||
name: 'get_weather',
|
||||
arguments: '{"city":"Tok',
|
||||
},
|
||||
],
|
||||
finishReason: 'tool_call',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat gpt-4o',
|
||||
span_id: '5e00000000000005',
|
||||
time_unix: 1789627395000,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":"Summarise the trip plan."}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":[{"type":"text","text":"Paris, 2 nights, arriving"}],"stop_reason":"pause_turn"}]',
|
||||
'gen_ai.provider.name': 'anthropic',
|
||||
'gen_ai.request.model': 'claude-sonnet-4',
|
||||
'gen_ai.usage.input_tokens': 22,
|
||||
'gen_ai.usage.output_tokens': 7,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.000055,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00007000000000000001,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000125,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', content: 'Summarise the trip plan.' }],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', content: 'Paris, 2 nights, arriving' }],
|
||||
finishReason: 'pause_turn',
|
||||
},
|
||||
],
|
||||
kind_string: 'Client',
|
||||
name: 'chat claude-sonnet-4',
|
||||
span_id: '5e00000000000006',
|
||||
time_unix: 1789627397500,
|
||||
},
|
||||
{
|
||||
...SPAN_DEFAULTS,
|
||||
attributes: {
|
||||
'gen_ai.input.messages':
|
||||
'[{"role":"user","content":"Confirm the booking."}]',
|
||||
'gen_ai.operation.name': 'chat',
|
||||
'gen_ai.output.messages':
|
||||
'[{"role":"assistant","content":"Booking failed upstream.","finish_reason":"error"}]',
|
||||
'gen_ai.provider.name': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4o',
|
||||
'gen_ai.usage.input_tokens': 12,
|
||||
'gen_ai.usage.output_tokens': 5,
|
||||
'signoz.gen_ai.usage.input_tokens.cost': 0.000030000000000000004,
|
||||
'signoz.gen_ai.usage.output_tokens.cost': 0.00005,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00008,
|
||||
},
|
||||
duration_nano: 900000000,
|
||||
formatted_input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', content: 'Confirm the booking.' }],
|
||||
},
|
||||
],
|
||||
formatted_output: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', content: 'Booking failed upstream.' }],
|
||||
finishReason: 'error',
|
||||
},
|
||||
],
|
||||
has_error: true,
|
||||
kind_string: 'Client',
|
||||
name: 'chat gpt-4o',
|
||||
span_id: '5e00000000000007',
|
||||
status_code: 2,
|
||||
status_code_string: 'Error',
|
||||
time_unix: 1789627400000,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export type MessagePart =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'thinking'; content?: string }
|
||||
| {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
server?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'media';
|
||||
modality?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
| { type: 'generic'; content: string };
|
||||
|
||||
export interface ThreadMessage {
|
||||
// system | user | assistant | tool; any other value renders headerless.
|
||||
role?: string;
|
||||
content: MessagePart[];
|
||||
// stop | tool_call | length | content_filter | error, or provider-specific.
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
export type ThreadSpan = SpanV3 & {
|
||||
formatted_input?: ThreadMessage[];
|
||||
formatted_output?: ThreadMessage[];
|
||||
};
|
||||
|
||||
/** Response of GET /api/v1/traces/{id}/thread. */
|
||||
export interface TraceThreadResponse {
|
||||
spans: ThreadSpan[];
|
||||
prevCursor?: string;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export enum ThreadView {
|
||||
Formatted = 'formatted',
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
export enum AnchorStatus {
|
||||
None = 'none',
|
||||
Loading = 'loading',
|
||||
Found = 'found',
|
||||
// Backend anchors on the span's position but drops it for lacking messages.
|
||||
NoMessages = 'noMessages',
|
||||
NotFound = 'notFound',
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { collectToolCallIds } from '../AIThreadMessage/utils';
|
||||
import { AnchorStatus, ThreadSpan } from './types';
|
||||
|
||||
export function buildToolCallIndex(spans: ThreadSpan[]): Set<string> {
|
||||
return new Set(
|
||||
spans.flatMap((span) => [
|
||||
...collectToolCallIds(span.formatted_input ?? []),
|
||||
...collectToolCallIds(span.formatted_output ?? []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnchorStatus(
|
||||
anchorSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
): AnchorStatus {
|
||||
if (!anchorSpanId) {
|
||||
return AnchorStatus.None;
|
||||
}
|
||||
if (isLoading) {
|
||||
return AnchorStatus.Loading;
|
||||
}
|
||||
if (spans.length === 0) {
|
||||
return AnchorStatus.NotFound;
|
||||
}
|
||||
return spans.some((span) => span.span_id === anchorSpanId)
|
||||
? AnchorStatus.Found
|
||||
: AnchorStatus.NoMessages;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -33,10 +33,5 @@
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
|
||||
import tooltipStyles from '../TraceTooltip/TraceTooltip.module.scss';
|
||||
import styles from './FlamegraphCanvas.module.scss';
|
||||
|
||||
function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
@@ -203,7 +205,7 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
const tooltipElement = tooltipContent
|
||||
? createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
className={cx(tooltipStyles.surface, styles.tooltip)}
|
||||
style={{
|
||||
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
|
||||
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
isTotal?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TooltipRow({
|
||||
label,
|
||||
value,
|
||||
isTotal,
|
||||
testId,
|
||||
}: TooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row} data-testid={testId}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isTotal ? undefined : 'muted'}
|
||||
truncate={1}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small" truncate={1} className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TooltipRow;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TooltipSection({ children }: TooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TooltipSection;
|
||||
@@ -0,0 +1,82 @@
|
||||
// Figma `dark/shadow-dropdown` popover surface, shared by every trace-details
|
||||
// tooltip so they look the same.
|
||||
$surface-background: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
$surface-border-color: var(--l2-border);
|
||||
$surface-radius: var(--radius-2);
|
||||
$surface-padding: var(--spacing-4);
|
||||
$surface-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
// For plain elements, e.g. the flamegraph's cursor-following portal.
|
||||
.surface {
|
||||
background-color: $surface-background;
|
||||
border: 1px solid $surface-border-color;
|
||||
border-radius: $surface-radius;
|
||||
padding: $surface-padding;
|
||||
box-shadow: $surface-shadow;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
// For @signozhq/ui Tooltip content: it paints both the bubble and the arrow
|
||||
// from these variables.
|
||||
.tooltipContent {
|
||||
--tooltip-background: #{$surface-background};
|
||||
--tooltip-border-color: #{$surface-border-color};
|
||||
--tooltip-border-radius: #{$surface-radius};
|
||||
--tooltip-padding: #{$surface-padding};
|
||||
--tooltip-box-shadow: #{$surface-shadow};
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.title {
|
||||
--typography-font-family: var(--code-small-500-font-family);
|
||||
--typography-font-size: var(--code-small-500-font-size);
|
||||
--typography-font-weight: var(--code-small-500-font-weight);
|
||||
--typography-line-height: var(--code-small-500-line-height);
|
||||
letter-spacing: var(--code-small-500-letter-spacing);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex-shrink: 0;
|
||||
max-width: 60%;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -217,8 +217,9 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -408,6 +409,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
@@ -33,7 +32,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -44,6 +49,8 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
|
||||
import { isAiSpan } from '../../../utils/genAi';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
@@ -328,14 +335,23 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
{isAiSpan(span) ? (
|
||||
<Sparkle
|
||||
size={10}
|
||||
color={effectiveColor}
|
||||
fill={effectiveColor}
|
||||
className={styles.aiSpanIcon}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className={styles.treeLabel}>
|
||||
@@ -971,9 +987,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
<SpanHintBadge span={span} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -53,3 +53,8 @@ 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',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,89 +1,18 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
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 handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
setFilteredSpanIds(spanIds);
|
||||
@@ -92,453 +21,26 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
// The filter query lives in Filters' local state, which unmounts with the
|
||||
// Overview tab; drop its result too so both start empty on return.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
if (!isOverview) {
|
||||
handleFilteredSpansChange([], false);
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filterMetadata = useMemo(
|
||||
() => ({
|
||||
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
|
||||
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
|
||||
traceId: traceId || '',
|
||||
}),
|
||||
[
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceId,
|
||||
],
|
||||
);
|
||||
|
||||
const traceMetadataForHeader = useMemo(():
|
||||
| TraceMetadataForHeader
|
||||
| undefined => {
|
||||
const payload = traceData?.payload;
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const rootSpan = payload.spans?.find((s) => s.level === 0);
|
||||
return {
|
||||
startTimestampMillis: payload.startTimestampMillis,
|
||||
endTimestampMillis: payload.endTimestampMillis,
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
totalSpansCount: payload.totalSpansCount || 0,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
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
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
<TraceDetailsWrapper>
|
||||
<TraceDetailsHeader onFilteredSpansChange={handleFilteredSpansChange} />
|
||||
{isOverview ? (
|
||||
<TraceDetailsOverview
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { AiSpanKind, getAiSpanKind, isAiSpan } from '../genAi';
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getAiSpanKind', () => {
|
||||
it.each<[Record<string, string | number>, AiSpanKind]>([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
|
||||
[
|
||||
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
|
||||
AiSpanKind.Tool,
|
||||
],
|
||||
[
|
||||
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
|
||||
AiSpanKind.Agent,
|
||||
],
|
||||
])('classifies %p as %s', (attributes, kind) => {
|
||||
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
|
||||
});
|
||||
|
||||
it('returns undefined for non-AI spans and empty values', () => {
|
||||
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
|
||||
expect(isAiSpan(spanWith({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
56
frontend/src/pages/TraceDetailsV3/utils/genAi.ts
Normal file
56
frontend/src/pages/TraceDetailsV3/utils/genAi.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
providerName: 'gen_ai.provider.name',
|
||||
system: 'gen_ai.system',
|
||||
requestModel: 'gen_ai.request.model',
|
||||
responseModel: 'gen_ai.response.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
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 {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
const value = span.attributes?.[key] ?? span.resource?.[key];
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user