mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-09 11:40:42 +01:00
Compare commits
1 Commits
feat/trace
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ce47351a |
@@ -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] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -95,3 +96,17 @@ export function formatTokens(value: number): string {
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,5 +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 {
|
||||
return <div data-testid="trace-details-thread" />;
|
||||
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;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const GEN_AI_KEYS = {
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
inputMessages: 'gen_ai.input.messages',
|
||||
outputMessages: 'gen_ai.output.messages',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
|
||||
Reference in New Issue
Block a user