mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-20 09:00:40 +01:00
Compare commits
11 Commits
feat/trace
...
nv/context
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a5fcabda | ||
|
|
e3dc4ea9ac | ||
|
|
8551acf07d | ||
|
|
516ce300bc | ||
|
|
e6c8bae0c7 | ||
|
|
6210c0ac4b | ||
|
|
0f370aac64 | ||
|
|
b85b2fa87c | ||
|
|
2a9246bfe3 | ||
|
|
e59afbec38 | ||
|
|
b1e2164739 |
@@ -1,7 +1,6 @@
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { formUrlParams } from 'utils/traceUtils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
@@ -21,7 +20,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
}
|
||||
|
||||
return `${generatePath(ROUTES.TRACE_DETAIL, { id: traceId })}${formUrlParams({
|
||||
tab: TraceDetailsTab.Thread,
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { TraceDetailsTab } from 'pages/TraceDetailsV3/constants';
|
||||
import { getMs } from 'utils/timeUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -45,10 +44,7 @@ function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
pathname: generatePath(ROUTES.TRACE_DETAIL, { id: text }),
|
||||
search: `?tab=${TraceDetailsTab.Thread}`,
|
||||
}}
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { useCopySpanLink } from '../useCopySpanLink';
|
||||
|
||||
const mockCopy = jest.fn();
|
||||
|
||||
jest.mock('react-use', () => ({
|
||||
__esModule: true,
|
||||
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopy],
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn() },
|
||||
}));
|
||||
|
||||
// The router only knows the stale URL; nuqs has since written ?tab=thread.
|
||||
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
|
||||
<MemoryRouter initialEntries={['/trace/abc?tab=overview&spanId=old']}>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const clickEvent = {
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
} as unknown as MouseEvent<HTMLElement>;
|
||||
|
||||
describe('useCopySpanLink', () => {
|
||||
beforeEach(() => {
|
||||
mockCopy.mockClear();
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams('tab=thread&spanId=old'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('builds the link from the live URL, keeping its tab', () => {
|
||||
const { result } = renderHook(() => useCopySpanLink({ span_id: 'new' }), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
result.current.onSpanCopy(clickEvent);
|
||||
|
||||
const link = new URL(mockCopy.mock.calls[0][0]);
|
||||
expect(link.pathname).toBe('/trace/abc');
|
||||
expect(link.searchParams.get('tab')).toBe('thread');
|
||||
expect(link.searchParams.get('spanId')).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,8 @@ import { MouseEventHandler, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// Accepts both V2 (spanId) and V3 (span_id) span shapes
|
||||
// TODO: Remove V2 (spanId) support when phasing out V2
|
||||
@@ -15,6 +15,7 @@ interface SpanLike {
|
||||
export const useCopySpanLink = (
|
||||
span?: SpanLike,
|
||||
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
@@ -27,22 +28,21 @@ export const useCopySpanLink = (
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
urlQuery.delete('spanId');
|
||||
|
||||
const id = span.span_id || span.spanId;
|
||||
if (id) {
|
||||
params.set('spanId', id);
|
||||
urlQuery.set('spanId', id);
|
||||
}
|
||||
|
||||
const link = getAbsoluteUrl(`${pathname}?${params.toString()}`);
|
||||
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
|
||||
|
||||
setCopy(link);
|
||||
toast.success('Copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
},
|
||||
[span, pathname, setCopy],
|
||||
[span, urlQuery, pathname, setCopy],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import ReactMarkdown, { uriTransformer } from 'react-markdown';
|
||||
import type { Components } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// Model output is untrusted: never add `rehype-raw`, so raw HTML renders as text.
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS: Components = {
|
||||
a: ({ node: _node, children, ...props }): JSX.Element => (
|
||||
<a {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// Images become links so viewing a message never fetches a third-party URL.
|
||||
img: ({ src, alt }): JSX.Element => (
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
{alt || src}
|
||||
</a>
|
||||
),
|
||||
};
|
||||
|
||||
interface AIMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AIMarkdown({ content, className }: AIMarkdownProps): JSX.Element {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
// Images render as links, so their URIs need the same sanitising links get.
|
||||
transformImageUri={uriTransformer}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
AIMarkdown.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default AIMarkdown;
|
||||
@@ -1,376 +0,0 @@
|
||||
// Design token Paragraph/Base/400.
|
||||
@mixin paragraph-base {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Typography sets size, weight and line height; the mixin adds the rest.
|
||||
.roleLabel {
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
@include paragraph-base;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(:is(ol, ul)) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
|
||||
:global(li::marker) {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
margin: var(--spacing-6) 0 var(--spacing-4);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2)) {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
}
|
||||
|
||||
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:global(strong) {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:global(code:not(pre code)) {
|
||||
padding: 0 var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l3-background);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
:global(pre) {
|
||||
position: relative;
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
// The highlighter sets its box with inline styles; syntax colours stay its own.
|
||||
:global(pre > div:not(.code-copy-btn)) {
|
||||
margin: 0 !important;
|
||||
padding: var(--spacing-4) var(--spacing-5) !important;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2) !important;
|
||||
background: var(--l1-background) !important;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:global(pre code) {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
:global(blockquote) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-5);
|
||||
border-left: 2px solid var(--l3-border);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
margin: var(--spacing-6) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
min-width: 0;
|
||||
|
||||
@include paragraph-base;
|
||||
|
||||
&.isPlain {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
|
||||
&[data-tone='info'] {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-2);
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.chip {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolLinkIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
min-width: 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
gap: var(--spacing-2);
|
||||
padding: 0 !important;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// Unboxed: the chevron sits right after the label.
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: none !important;
|
||||
margin-inline-end: 0 !important;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
@include paragraph-base;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-expand-icon) {
|
||||
height: auto !important;
|
||||
padding-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-4) 0 0 !important;
|
||||
}
|
||||
|
||||
&.boxed {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
padding: var(--spacing-3) var(--spacing-5) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: auto !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
// The block is the box; nested text drops its own.
|
||||
.textBox {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleChevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.jsonBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jsonCollapsed {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jsonCopy {
|
||||
position: absolute;
|
||||
top: var(--spacing-2);
|
||||
right: var(--spacing-2);
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { MessageType, ThreadMessage } from './types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import RoleLabel from './RoleLabel';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
messageType?: MessageType;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, parts: messageParts, finish_reason: finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
const isOutput = messageType === MessageType.Output;
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{messageParts.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
messageParts.map((part, index) => (
|
||||
<MessagePart
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' || role === 'developer' ? (
|
||||
<CollapsibleBlock
|
||||
label={roleLabel && <RoleLabel label={roleLabel} />}
|
||||
testId="ai-message-system-toggle"
|
||||
>
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{(roleLabel || isOutput) && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel && <RoleLabel label={roleLabel} />}
|
||||
{isOutput && (
|
||||
<Badge
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
textTransform="none"
|
||||
testId="ai-message-output-badge"
|
||||
>
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
messageType: MessageType.Input,
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const PANEL_KEY = 'content';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
// Toggling must not count as a click on the message card; the body still does.
|
||||
const stopHeaderClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest('.ant-collapse-header')) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div onClick={stopHeaderClick}>
|
||||
<Collapse
|
||||
ghost
|
||||
className={cx(styles.collapsible, isBoxed && styles.boxed)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.collapsibleChevron} data-testid={testId}>
|
||||
{isActive ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</span>
|
||||
)}
|
||||
items={[{ key: PANEL_KEY, label, children }]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
|
||||
import AIMarkdown from './AIMarkdown';
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function ExpandableText({ text }: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AIMarkdown className={styles.text} content={visibleText} />
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { MouseEvent, useMemo, useRef, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
// Branch toggles (arrow, key, summary); PrettyView already stops its actions menu.
|
||||
const TREE_TOGGLES = '.pretty-view__nested-row > :not(ul)';
|
||||
const NO_ACTIONS = { leaf: [], nested: [] };
|
||||
// 10 tree rows: 18px line-height plus 2px padding top and bottom.
|
||||
const COLLAPSED_MAX_HEIGHT = 10 * 22;
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const text = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const { height: treeHeight } = useResizeObserver(treeRef);
|
||||
const isLong = treeHeight > COLLAPSED_MAX_HEIGHT;
|
||||
const isClamped = isLong && !isExpanded;
|
||||
|
||||
const stopToggleClick = (event: MouseEvent): void => {
|
||||
if ((event.target as HTMLElement).closest(TREE_TOGGLES)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.jsonBlock}>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className={cx(styles.json, isClamped && styles.jsonCollapsed)}
|
||||
style={isClamped ? { maxHeight: COLLAPSED_MAX_HEIGHT } : undefined}
|
||||
onClick={stopToggleClick}
|
||||
data-testid="ai-json-block"
|
||||
>
|
||||
<span className={styles.jsonCopy}>
|
||||
<CopyButton
|
||||
value={text}
|
||||
size={12}
|
||||
ariaLabel="Copy JSON"
|
||||
testId="ai-json-copy"
|
||||
/>
|
||||
</span>
|
||||
<div ref={treeRef}>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
visibleActions={NO_ACTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-json-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { MessagePart as MessagePartType } from './types';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
import {
|
||||
isEmptyPart,
|
||||
toDisplayText,
|
||||
toToolCallKey,
|
||||
truncateBlobContent,
|
||||
} from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const EMPTY_PART = (
|
||||
<span className={styles.empty} data-testid="ai-message-empty-part">
|
||||
No content
|
||||
</span>
|
||||
);
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
canLinkToToolCall?: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element {
|
||||
if (isEmptyPart(part)) {
|
||||
return EMPTY_PART;
|
||||
}
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} />;
|
||||
case 'reasoning':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'compaction':
|
||||
return (
|
||||
<ThinkingPart
|
||||
label="Compaction"
|
||||
content={part.content ?? undefined}
|
||||
testId="ai-message-compaction-toggle"
|
||||
/>
|
||||
);
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.arguments}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'server_tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id ?? ''}
|
||||
name={part.name ?? ''}
|
||||
args={part.server_tool_call}
|
||||
type={part.type}
|
||||
/>
|
||||
);
|
||||
case 'tool_call_response':
|
||||
case 'server_tool_call_response': {
|
||||
const toolCallId = part.id ?? '';
|
||||
const toolCallKey = toToolCallKey(
|
||||
toolCallId,
|
||||
part.type === 'server_tool_call_response',
|
||||
);
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={toolCallId}
|
||||
toolCallKey={toolCallKey}
|
||||
type={part.type}
|
||||
content={toDisplayText(
|
||||
part.type === 'tool_call_response'
|
||||
? part.response
|
||||
: part.server_tool_call_response,
|
||||
)}
|
||||
isLinkEnabled={!!toolCallId && !!canLinkToToolCall?.(toolCallKey)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'blob':
|
||||
case 'file':
|
||||
case 'uri':
|
||||
return <JsonBlock data={truncateBlobContent(part)} />;
|
||||
case 'generic':
|
||||
return <TextContent text={toDisplayText(part.content)} />;
|
||||
default:
|
||||
return <TextContent text={toDisplayText(part)} />;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
canLinkToToolCall: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
// Plain: no chip background, for headers like a tool call's.
|
||||
isPlain?: boolean;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
isPlain = false,
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={cx(styles.chip, isPlain && styles.isPlain)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
isPlain: false,
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface RoleLabelProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Message role in the design's Paragraph/Base/400 style. */
|
||||
function RoleLabel({ label }: RoleLabelProps): JSX.Element {
|
||||
return (
|
||||
<Typography.Text size="base" weight="normal" className={styles.roleLabel}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleLabel;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as markdown. */
|
||||
function TextContent({ text }: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextContent;
|
||||
@@ -1,34 +0,0 @@
|
||||
import CollapsibleBlock from '../CollapsibleBlock';
|
||||
import TextContent from '../TextContent';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ThinkingPartProps {
|
||||
content?: string;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThinkingPart({
|
||||
content,
|
||||
label = 'Thinking',
|
||||
testId = 'ai-message-thinking-toggle',
|
||||
}: ThinkingPartProps): JSX.Element {
|
||||
return (
|
||||
<CollapsibleBlock label={label} isBoxed testId={testId}>
|
||||
{content ? (
|
||||
<TextContent text={content} />
|
||||
) : (
|
||||
<span className={styles.empty}> - </span>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
|
||||
ThinkingPart.defaultProps = {
|
||||
content: undefined,
|
||||
label: 'Thinking',
|
||||
testId: 'ai-message-thinking-toggle',
|
||||
};
|
||||
|
||||
export default ThinkingPart;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import PartChip from '../PartChip';
|
||||
import { parseJsonObject, TOOL_CALL_ID_ATTR, toToolCallKey } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolCallPartProps {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
type: 'tool_call' | 'server_tool_call';
|
||||
}
|
||||
|
||||
function ToolCallPart({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
type,
|
||||
}: ToolCallPartProps): JSX.Element {
|
||||
// Streamed calls can carry truncated, unparseable argument strings.
|
||||
const json = useMemo(() => {
|
||||
if (typeof args === 'string') {
|
||||
return parseJsonObject(args);
|
||||
}
|
||||
return typeof args === 'object' && args !== null ? args : undefined;
|
||||
}, [args]);
|
||||
|
||||
const toolCallAttr = {
|
||||
[TOOL_CALL_ID_ATTR]: toToolCallKey(id, type === 'server_tool_call'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
|
||||
<PartChip
|
||||
label={type}
|
||||
tone="info"
|
||||
isPlain
|
||||
value={
|
||||
<>
|
||||
<span className={styles.chipKey}>name :</span>
|
||||
<span className={styles.toolName}>{name}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{json ? (
|
||||
<JsonBlock data={json} />
|
||||
) : (
|
||||
<span className={styles.empty}> - </span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolCallPart;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
toolCallKey: string;
|
||||
type: 'tool_call_response' | 'server_tool_call_response';
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
toolCallKey,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallKey);
|
||||
};
|
||||
|
||||
const chip = (
|
||||
<PartChip
|
||||
label={type}
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
{isLinkEnabled && (
|
||||
<CornerUpRight size={12} className={styles.toolLinkIcon} />
|
||||
)}
|
||||
</PartChip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
{isLinkEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
onClick={handleLinkClick}
|
||||
title="Go to tool call"
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
) : (
|
||||
chip
|
||||
)}
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
@@ -1,57 +0,0 @@
|
||||
import type {
|
||||
AiobservabilitytypesBlobPartDTO,
|
||||
AiobservabilitytypesCompactionPartDTO,
|
||||
AiobservabilitytypesFilePartDTO,
|
||||
AiobservabilitytypesOutputMessageDTO,
|
||||
AiobservabilitytypesReasoningPartDTO,
|
||||
AiobservabilitytypesServerToolCallPartDTO,
|
||||
AiobservabilitytypesServerToolCallResponsePartDTO,
|
||||
AiobservabilitytypesTextPartDTO,
|
||||
AiobservabilitytypesToolCallRequestPartDTO,
|
||||
AiobservabilitytypesToolCallResponsePartDTO,
|
||||
AiobservabilitytypesUriPartDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// Generated enum `type`s as string literals, so `part.type === 'text'` narrows.
|
||||
type Part<T extends { type: string }> = Omit<T, 'type'> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
// Keys the schema marks required may still be missing.
|
||||
type LoosePart<T extends { type: string }> = Partial<Omit<T, 'type'>> & {
|
||||
type: `${T['type']}`;
|
||||
};
|
||||
|
||||
/** OTel GenAI message part; `text` content is always normalised to a string. */
|
||||
export type MessagePart =
|
||||
| Part<AiobservabilitytypesTextPartDTO>
|
||||
| LoosePart<AiobservabilitytypesReasoningPartDTO>
|
||||
| LoosePart<AiobservabilitytypesCompactionPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallRequestPartDTO>
|
||||
| LoosePart<AiobservabilitytypesToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallPartDTO>
|
||||
| LoosePart<AiobservabilitytypesServerToolCallResponsePartDTO>
|
||||
| LoosePart<AiobservabilitytypesBlobPartDTO>
|
||||
| LoosePart<AiobservabilitytypesFilePartDTO>
|
||||
| LoosePart<AiobservabilitytypesUriPartDTO>
|
||||
| { type: 'generic'; content?: unknown };
|
||||
|
||||
export type MessagePartType = MessagePart['type'];
|
||||
|
||||
export type MediaMessagePart = Extract<
|
||||
MessagePart,
|
||||
{ type: 'blob' | 'file' | 'uri' }
|
||||
>;
|
||||
|
||||
// role: system | developer | user | assistant | tool; any other value renders headerless.
|
||||
// finish_reason: stop | tool_call | length | content_filter | compaction | error, or provider-specific.
|
||||
export type ThreadMessage = Partial<
|
||||
Omit<AiobservabilitytypesOutputMessageDTO, 'parts'>
|
||||
> & {
|
||||
parts: MessagePart[];
|
||||
};
|
||||
|
||||
export enum MessageType {
|
||||
Input = 'input',
|
||||
Output = 'output',
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
canLinkToToolCall: (toolCallKey: string) => boolean;
|
||||
onToolLinkClick: (toolCallKey: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for keys in `toolCallKeys`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallKeys: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const canLinkToToolCall = useCallback(
|
||||
(toolCallKey: string): boolean => toolCallKeys.has(toolCallKey),
|
||||
[toolCallKeys],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallKey: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallKey)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { canLinkToToolCall, onToolLinkClick };
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { attempt, isError, isObjectLike } from 'lodash-es';
|
||||
|
||||
import { MediaMessagePart, MessagePart, ThreadMessage } from './types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
const MAX_BLOB_PREVIEW_LENGTH = 100;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
developer: 'Developer',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
// Provider values the backend could not map fall back to info.
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
stop: 'success',
|
||||
tool_call: 'success',
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
compaction: 'info',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string | null): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason.toLowerCase()]) || 'info';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const parsed: unknown = attempt(JSON.parse, text);
|
||||
return isObjectLike(parsed) && !isError(parsed)
|
||||
? (parsed as object)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDisplayText(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
/** Text and generic parts with only whitespace have nothing to render. */
|
||||
export function isEmptyPart(part: MessagePart): boolean {
|
||||
if (part.type === 'text') {
|
||||
return part.content.trim() === '';
|
||||
}
|
||||
if (part.type === 'generic') {
|
||||
return toDisplayText(part.content).trim() === '';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Blob content is inline base64; only a prefix is shown. */
|
||||
export function truncateBlobContent(part: MediaMessagePart): MediaMessagePart {
|
||||
if (
|
||||
part.type !== 'blob' ||
|
||||
!part.content ||
|
||||
part.content.length <= MAX_BLOB_PREVIEW_LENGTH
|
||||
) {
|
||||
return part;
|
||||
}
|
||||
return {
|
||||
...part,
|
||||
content: `${part.content.slice(0, MAX_BLOB_PREVIEW_LENGTH)}…`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server and client calls can share an id, so links match on kind too.
|
||||
export function toToolCallKey(id: string, isServer: boolean): string {
|
||||
return `${isServer ? 'server' : 'client'}:${id}`;
|
||||
}
|
||||
|
||||
export function collectToolCallKeys(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.parts.flatMap((part) =>
|
||||
(part.type === 'tool_call' || part.type === 'server_tool_call') && part.id
|
||||
? [toToolCallKey(part.id, part.type === 'server_tool_call')]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,7 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
// Below the row: above it the tooltip covers the Overview / Thread tabs.
|
||||
<Tooltip title={tooltip} side="bottom">
|
||||
<Tooltip title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
@use '../../../../styles/scrollbar' as *;
|
||||
|
||||
.accordion {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
align-items: center !important;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4) !important;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content) {
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import { Collapse } from 'antd';
|
||||
|
||||
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../../AIThreadMessage/types';
|
||||
import MessagesSection from '../../SpanMessages/MessagesSection';
|
||||
import { RawMessagesValue } from '../../utils/spanMessages';
|
||||
import { ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
|
||||
import styles from './MessagesAccordion.module.scss';
|
||||
|
||||
const PANEL_KEY = 'messages';
|
||||
|
||||
interface MessagesAccordionProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesAccordionProps): JSX.Element | null {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const canFormat = messages.length > 0;
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
if (!canFormat && !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stopToggle = (event: MouseEvent): void => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<section data-testid={testId}>
|
||||
<Collapse
|
||||
className={styles.accordion}
|
||||
activeKey={isOpen ? [PANEL_KEY] : []}
|
||||
onChange={(): void => setIsOpen((prev) => !prev)}
|
||||
expandIconPosition="end"
|
||||
expandIcon={({ isActive }): JSX.Element => (
|
||||
<span className={styles.chevron} data-testid={`${testId}-toggle`}>
|
||||
{isActive ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
key: PANEL_KEY,
|
||||
label: (
|
||||
<span className={styles.title}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen &&
|
||||
canFormat && (
|
||||
// The view toggle lives in the header; its clicks must not fold the panel.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<span onClick={stopToggle}>
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={effectiveView}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
messageType={messageType}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
@@ -1,7 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { MessageType } from '../../AIThreadMessage/types';
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
|
||||
rootRef,
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef} data-testid="span-ai-messages">
|
||||
<MessagesAccordion
|
||||
icon={<ArrowUpRight size={14} />}
|
||||
title="Input"
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-ai-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesAccordion
|
||||
icon={<ArrowDownRight size={14} />}
|
||||
title="Output"
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-ai-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -34,15 +34,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Inside the Overview tab there's no flex gap, so match .panelBody's 16px.
|
||||
.tabAiMessages {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Logs-tab footer. Sibling of the scrolling .panelBody inside .panel, so it's
|
||||
// a true fixed footer flush with the panel's bottom edge — always visible
|
||||
// regardless of how the body is scrolled.
|
||||
|
||||
@@ -48,7 +48,6 @@ 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';
|
||||
@@ -282,7 +281,6 @@ function SpanDetailsContent({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
const aiMessages = <SpanAIMessages span={selectedSpan} />;
|
||||
const eventsCount = selectedSpan.events?.length || 0;
|
||||
|
||||
const spanDetailTabs: TabsItemProps[] = [
|
||||
@@ -293,7 +291,6 @@ function SpanDetailsContent({
|
||||
children: (
|
||||
<>
|
||||
{isWide && summary}
|
||||
{isWide && <div className={styles.tabAiMessages}>{aiMessages}</div>}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -372,7 +369,6 @@ function SpanDetailsContent({
|
||||
<>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
{!isWide && aiMessages}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
<Tabs
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import AIThreadMessage, {
|
||||
AIThreadMessageProps,
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
type MessageProps = Pick<
|
||||
AIThreadMessageProps,
|
||||
'messageType' | 'canLinkToToolCall' | 'onToolLinkClick'
|
||||
>;
|
||||
|
||||
interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
// Messages have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
message={message}
|
||||
messageType={messageType}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
@@ -1,21 +0,0 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} />;
|
||||
}
|
||||
return (
|
||||
<pre className={styles.raw} data-testid="span-raw-messages">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -1,51 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l1-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.missing {
|
||||
padding: var(--spacing-10) var(--spacing-6);
|
||||
border-radius: var(--radius-2);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { MessageType } from '../AIThreadMessage/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
hasRawMessages,
|
||||
} from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface SpanMessagesProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'canLinkToToolCall' | 'onToolLinkClick'
|
||||
> {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Input and output messages of one AI span, shared by Thread and Span Details. */
|
||||
function SpanMessages({
|
||||
span,
|
||||
view,
|
||||
className,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
data-testid="span-messages-empty"
|
||||
>
|
||||
This span has no input or output.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFormatted) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, className)}>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpanMessages.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default SpanMessages;
|
||||
@@ -29,10 +29,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
@@ -13,20 +13,16 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -64,14 +60,6 @@ function TraceDetailsHeader({
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -105,14 +93,13 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
@@ -133,10 +120,9 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -152,7 +138,6 @@ function TraceDetailsHeader({
|
||||
icon
|
||||
color="secondary"
|
||||
aria-label="Analytics"
|
||||
testId="trace-analytics-button"
|
||||
onClick={handleToggleAnalytics}
|
||||
>
|
||||
<ChartPie size={14} />
|
||||
@@ -187,20 +172,16 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
{showTraceDetails && (
|
||||
<div className={styles.subHeader}>
|
||||
{!traceSummary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
{traceSummary ? (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={
|
||||
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
@@ -208,11 +189,13 @@ function TraceDetailsHeader({
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{
|
||||
value: TraceDetailsTab.Overview,
|
||||
label: 'Overview',
|
||||
testId: 'trace-details-tab-overview',
|
||||
},
|
||||
{
|
||||
value: TraceDetailsTab.Thread,
|
||||
label: 'Thread',
|
||||
testId: 'trace-details-tab-thread',
|
||||
},
|
||||
];
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.tabs}>
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
size="sm"
|
||||
items={TAB_ITEMS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -50,7 +50,6 @@ function TraceOptionsMenu({
|
||||
type: 'item',
|
||||
value: 'toggle-trace-details',
|
||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||
testId: 'toggle-trace-details',
|
||||
onClick: onToggleTraceDetails,
|
||||
},
|
||||
{
|
||||
@@ -128,7 +127,6 @@ function TraceOptionsMenu({
|
||||
icon
|
||||
color="secondary"
|
||||
aria-label="Trace options"
|
||||
testId="trace-options-button"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -316,88 +315,3 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({
|
||||
data: {
|
||||
...SUMMARY,
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
hasMissingSpans: true,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('trace-analytics-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('trace-options-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('trace-analytics-button'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('trace-options-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-options-button'));
|
||||
await user.click(await screen.findByTestId('toggle-trace-details'));
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-analytics-button'));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import {
|
||||
SPAN_DETAILS_PANEL_MIN_WIDTH,
|
||||
SpanDetailVariant,
|
||||
} from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
onHasTraceDataChange: (hasTraceData: boolean) => void;
|
||||
dataLoadedFiredForRef: MutableRefObject<string>;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
onHasTraceDataChange,
|
||||
dataLoadedFiredForRef,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
}, [safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
dataLoadedFiredForRef,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
|
||||
|
||||
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const hasTraceData = !showNoData && allSpans.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onHasTraceDataChange(hasTraceData);
|
||||
return (): void => onHasTraceDataChange(false);
|
||||
}, [hasTraceData, onHasTraceDataChange]);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -1,29 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 428px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
|
||||
|
||||
import styles from './ThreadNoAiSpans.module.scss';
|
||||
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<div className={styles.message}>
|
||||
<span className={styles.emoji} aria-hidden>
|
||||
🚏
|
||||
</span>
|
||||
<div className={styles.paragraphs}>
|
||||
<Typography size="base" weight="medium" color="muted">
|
||||
Looks like this trace does not have any spans from AI-related actions.
|
||||
</Typography>
|
||||
<Typography size="base" weight="medium" className={styles.highlight}>
|
||||
Thread view summarising your entire AI-journey history will appear here
|
||||
when the spans have the necessary datapoints.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
suffix={<ArrowRight size={12} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
|
||||
}}
|
||||
testId="thread-empty-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadNoAiSpans;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadPageLoaderProps {
|
||||
testId: string;
|
||||
}
|
||||
|
||||
// Mirrors a span card: a short message, then an output with a block and a chip.
|
||||
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.pageLoader} data-testid={testId}>
|
||||
<Skeleton active title={{ width: 48 }} paragraph={{ rows: 1 }} />
|
||||
<Skeleton
|
||||
active
|
||||
title={{ width: 112 }}
|
||||
paragraph={{ rows: 3, width: ['100%', '100%', 160] }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadPageLoader;
|
||||
@@ -1,29 +0,0 @@
|
||||
.card {
|
||||
position: relative;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--primary);
|
||||
outline-offset: var(--spacing-1);
|
||||
}
|
||||
|
||||
&.isSelected::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
border-radius: var(--radius-2) 0 0 var(--radius-2);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { KeyboardEvent, memo, useMemo, useRef } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
|
||||
import SpanMessages from '../SpanMessages/SpanMessages';
|
||||
import { ThreadSpan, ThreadView } from './types';
|
||||
import { buildToolCallIndex } from './utils';
|
||||
|
||||
import styles from './ThreadSpanCard.module.scss';
|
||||
|
||||
interface ThreadSpanCardProps {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
isSelected: boolean;
|
||||
onSelect: (span: ThreadSpan) => void;
|
||||
}
|
||||
|
||||
function ThreadSpanCard({
|
||||
span,
|
||||
view,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: ThreadSpanCardProps): JSX.Element {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
// A result links only to a call in its own span.
|
||||
const toolCallKeys = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { canLinkToToolCall, onToolLinkClick } = useToolCallLinks(
|
||||
cardRef,
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
// Keys pressed on the card's own controls stay with them.
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(span);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- a <button> can't nest the card's own buttons
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
aria-label={`Span ${span.name}`}
|
||||
className={cx(styles.card, isSelected && styles.isSelected)}
|
||||
onClick={(): void => onSelect(span)}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-span-id={span.span_id}
|
||||
data-testid="thread-span-card"
|
||||
>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={view}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ThreadSpanCard);
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useTraceSummary } from '../TraceDetailsHeader/useTraceSummary';
|
||||
import { ThreadSpan } from './types';
|
||||
|
||||
import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const BOTTOM_DOCK_DEFAULT = 360;
|
||||
const BOTTOM_DOCK_MIN = 200;
|
||||
|
||||
interface ThreadSpanDetailsProps {
|
||||
panelState: DetailsPanelState;
|
||||
span: ThreadSpan;
|
||||
variant: SpanDetailVariant;
|
||||
onVariantChange: (variant: SpanDetailVariant) => void;
|
||||
}
|
||||
|
||||
function ThreadSpanDetails({
|
||||
panelState,
|
||||
span,
|
||||
variant,
|
||||
onVariantChange,
|
||||
}: ThreadSpanDetailsProps): JSX.Element {
|
||||
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
|
||||
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const { data: traceSummary } = useTraceSummary(traceId);
|
||||
|
||||
const panel = (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={span}
|
||||
variant={variant}
|
||||
onVariantChange={onVariantChange}
|
||||
traceStartTime={traceSummary?.startTimestampMillis}
|
||||
traceEndTime={traceSummary?.endTimestampMillis}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED_RIGHT) {
|
||||
return (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={width}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
{panel}
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return (
|
||||
<ResizableBox
|
||||
handle="top"
|
||||
defaultHeight={height}
|
||||
minHeight={BOTTOM_DOCK_MIN}
|
||||
onResize={setHeight}
|
||||
className={styles.bottomDock}
|
||||
>
|
||||
{panel}
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
export default ThreadSpanDetails;
|
||||
@@ -1,28 +0,0 @@
|
||||
@use '../../../styles/scrollbar' as *;
|
||||
|
||||
.list {
|
||||
@include custom-scrollbar;
|
||||
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// Padding, not margin: Virtuoso measures item boxes.
|
||||
.item {
|
||||
padding: var(--spacing-6) var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.pageLoader {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
import { ThreadSpan } from './types';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
// Virtuoso needs a positive index it can decrease as older spans are prepended.
|
||||
const FIRST_ITEM_INDEX_BASE = 1_000_000;
|
||||
const LOAD_AHEAD_PX = 200;
|
||||
|
||||
interface ThreadSpanListProps {
|
||||
spans: ThreadSpan[];
|
||||
spansAboveFirstPage: number;
|
||||
initialSpanIndex?: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
renderSpan: (span: ThreadSpan) => ReactNode;
|
||||
}
|
||||
|
||||
function ThreadSpanList({
|
||||
spans,
|
||||
spansAboveFirstPage,
|
||||
initialSpanIndex,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
renderSpan,
|
||||
}: ThreadSpanListProps): JSX.Element {
|
||||
const handleEndReached = useCallback((): void => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const handleStartReached = useCallback((): void => {
|
||||
if (hasPreviousPage && !isFetchingPreviousPage) {
|
||||
void fetchPreviousPage();
|
||||
}
|
||||
}, [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage]);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
Header: (): JSX.Element | null =>
|
||||
isFetchingPreviousPage ? (
|
||||
<div className={styles.item}>
|
||||
<ThreadPageLoader testId="thread-loading-previous" />
|
||||
</div>
|
||||
) : null,
|
||||
Footer: (): JSX.Element => (
|
||||
<div className={styles.footer}>
|
||||
{isFetchingNextPage && (
|
||||
<div className={styles.item}>
|
||||
<ThreadPageLoader testId="thread-loading-next" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
[isFetchingPreviousPage, isFetchingNextPage],
|
||||
);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
className={styles.list}
|
||||
data-testid="thread-span-list"
|
||||
data={spans}
|
||||
// 0 is allowed, negative is not (Virtuoso warns); the base fits 1,000,000 prepended spans.
|
||||
firstItemIndex={FIRST_ITEM_INDEX_BASE - spansAboveFirstPage}
|
||||
// Omitted, not undefined: an explicit undefined blanks the list, and 0 resets an early scroll.
|
||||
{...(initialSpanIndex === undefined
|
||||
? {}
|
||||
: { initialTopMostItemIndex: initialSpanIndex })}
|
||||
increaseViewportBy={LOAD_AHEAD_PX}
|
||||
computeItemKey={(_, span): string => span.span_id}
|
||||
itemContent={(_, span): JSX.Element => (
|
||||
<div className={styles.item}>{renderSpan(span)}</div>
|
||||
)}
|
||||
startReached={handleStartReached}
|
||||
endReached={handleEndReached}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanList;
|
||||
@@ -1,32 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
|
||||
|
||||
import styles from './ThreadSpanNotFound.module.scss';
|
||||
|
||||
interface ThreadSpanNotFoundProps {
|
||||
onGoBack: () => void;
|
||||
onShowThread: () => void;
|
||||
}
|
||||
|
||||
function ThreadSpanNotFound({
|
||||
onGoBack,
|
||||
onShowThread,
|
||||
}: ThreadSpanNotFoundProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-span-not-found">
|
||||
<span className={styles.icon}>
|
||||
<SearchX size={32} />
|
||||
</span>
|
||||
<span className={styles.title}>Span not found in this thread</span>
|
||||
<span className={styles.description}>
|
||||
The requested span isn't part of this trace's AI thread.
|
||||
</span>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onGoBack}
|
||||
testId="thread-span-not-found-go-back"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<MessagesSquare size={14} />}
|
||||
onClick={onShowThread}
|
||||
testId="thread-span-not-found-show-thread"
|
||||
>
|
||||
Show thread
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanNotFound;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { ThreadView } from './types';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ label: 'Formatted', value: ThreadView.Formatted },
|
||||
{ label: 'JSON', value: ThreadView.Json },
|
||||
];
|
||||
|
||||
interface ThreadViewToggleProps {
|
||||
value: ThreadView;
|
||||
onChange: (view: ThreadView) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThreadViewToggle({
|
||||
value,
|
||||
onChange,
|
||||
testId,
|
||||
}: ThreadViewToggleProps): JSX.Element {
|
||||
const handleChange = (next: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (next) {
|
||||
onChange(next as ThreadView);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
items={VIEW_OPTIONS}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ThreadViewToggle.defaultProps = {
|
||||
testId: 'thread-view-toggle',
|
||||
};
|
||||
|
||||
export default ThreadViewToggle;
|
||||
@@ -1,50 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: var(--spacing-6) var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.initialLoader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.bottomDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import history, { hasInAppHistory } from 'lib/history';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
import ThreadNoAiSpans from './ThreadNoAiSpans';
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
import ThreadSpanCard from './ThreadSpanCard';
|
||||
import ThreadSpanDetails from './ThreadSpanDetails';
|
||||
import ThreadSpanList from './ThreadSpanList';
|
||||
import ThreadSpanNotFound from './ThreadSpanNotFound';
|
||||
import ThreadViewToggle from './ThreadViewToggle';
|
||||
import { InterestedSpanStatus } from './types';
|
||||
import { getInterestedSpanStatus } from './utils';
|
||||
|
||||
import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const INITIAL_LOADER_COUNT = 4;
|
||||
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
const [view, setView] = useThreadView();
|
||||
const {
|
||||
interestedSpanId,
|
||||
clearInterestedSpan,
|
||||
selectedSpanId,
|
||||
selectSpan,
|
||||
panelState,
|
||||
} = useThreadSpanSelection();
|
||||
const {
|
||||
spans,
|
||||
spansAboveFirstPage,
|
||||
isLoading,
|
||||
isError,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
} = useTraceThread({ traceId: traceId || '', interestedSpanId });
|
||||
|
||||
const interestedSpanStatus = getInterestedSpanStatus(
|
||||
interestedSpanId,
|
||||
spans,
|
||||
isLoading,
|
||||
);
|
||||
const interestedSpanIndex = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId,
|
||||
);
|
||||
|
||||
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
|
||||
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
|
||||
const spanDetails = panelState.isOpen && selectedSpan && (
|
||||
<ThreadSpanDetails
|
||||
panelState={panelState}
|
||||
span={selectedSpan}
|
||||
variant={spanDetailVariant}
|
||||
onVariantChange={setSpanDetailVariant}
|
||||
/>
|
||||
);
|
||||
const hasNoThread =
|
||||
!isLoading &&
|
||||
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
|
||||
(isError || spans.length === 0);
|
||||
|
||||
const renderBody = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.initialLoader} data-testid="thread-loading">
|
||||
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ThreadPageLoader key={i} testId="thread-loading-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (interestedSpanStatus === InterestedSpanStatus.NotFound) {
|
||||
return (
|
||||
<ThreadSpanNotFound
|
||||
onGoBack={(): void => {
|
||||
// go back to the previous page
|
||||
if (hasInAppHistory()) {
|
||||
history.goBack();
|
||||
} else {
|
||||
void setTab(TraceDetailsTab.Overview);
|
||||
}
|
||||
}}
|
||||
onShowThread={clearInterestedSpan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasNoThread) {
|
||||
return <ThreadNoAiSpans />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{interestedSpanStatus === InterestedSpanStatus.NoMessages && (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
className={styles.notice}
|
||||
title="The requested span has no input or output messages. Showing the rest of the thread."
|
||||
testId="thread-anchor-no-messages"
|
||||
/>
|
||||
)}
|
||||
<ThreadSpanList
|
||||
key={interestedSpanId}
|
||||
spans={spans}
|
||||
spansAboveFirstPage={spansAboveFirstPage}
|
||||
initialSpanIndex={
|
||||
interestedSpanIndex === -1 ? undefined : interestedSpanIndex
|
||||
}
|
||||
hasNextPage={hasNextPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetchingPreviousPage={isFetchingPreviousPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
fetchPreviousPage={fetchPreviousPage}
|
||||
renderSpan={(span): JSX.Element => (
|
||||
<ThreadSpanCard
|
||||
span={span}
|
||||
view={view}
|
||||
isSelected={span.span_id === selectedSpanId}
|
||||
onSelect={selectSpan}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="trace-details-thread">
|
||||
<div className={styles.content}>
|
||||
{!hasNoThread && interestedSpanStatus !== InterestedSpanStatus.NotFound && (
|
||||
<div className={styles.toolbar}>
|
||||
<ThreadViewToggle
|
||||
value={view}
|
||||
onChange={(next): void => {
|
||||
void setView(next);
|
||||
}}
|
||||
/>
|
||||
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
|
||||
</div>
|
||||
)}
|
||||
{renderBody()}
|
||||
{isBottomDocked && spanDetails}
|
||||
</div>
|
||||
{!isBottomDocked && spanDetails}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { ThreadSpan } from '../types';
|
||||
|
||||
const SPAN_ID_PARAM = 'spanId';
|
||||
|
||||
interface ThreadSpanSelection {
|
||||
// The spanId the thread was opened with; it shapes the first page fetched.
|
||||
interestedSpanId?: string;
|
||||
clearInterestedSpan: () => void;
|
||||
selectedSpanId?: string;
|
||||
selectSpan: (span: ThreadSpan) => void;
|
||||
panelState: DetailsPanelState;
|
||||
}
|
||||
|
||||
export function useThreadSpanSelection(): ThreadSpanSelection {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const selectedSpanId = urlQuery.get(SPAN_ID_PARAM) || undefined;
|
||||
const [interestedSpanId, setInterestedSpanId] = useState(selectedSpanId);
|
||||
|
||||
const setSpanIdParam = useCallback(
|
||||
(spanId?: string): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
if (spanId) {
|
||||
params.set(SPAN_ID_PARAM, spanId);
|
||||
} else {
|
||||
params.delete(SPAN_ID_PARAM);
|
||||
}
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(): void => setSpanIdParam(undefined),
|
||||
[setSpanIdParam],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const { open: openPanel } = panelState;
|
||||
const selectSpan = useCallback(
|
||||
(span: ThreadSpan): void => {
|
||||
setSpanIdParam(span.span_id);
|
||||
openPanel();
|
||||
},
|
||||
[setSpanIdParam, openPanel],
|
||||
);
|
||||
|
||||
const clearInterestedSpan = useCallback((): void => {
|
||||
setInterestedSpanId(undefined);
|
||||
setSpanIdParam(undefined);
|
||||
}, [setSpanIdParam]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
interestedSpanId,
|
||||
clearInterestedSpan,
|
||||
selectedSpanId,
|
||||
selectSpan,
|
||||
panelState,
|
||||
}),
|
||||
[
|
||||
interestedSpanId,
|
||||
clearInterestedSpan,
|
||||
selectedSpanId,
|
||||
selectSpan,
|
||||
panelState,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useInfiniteQuery } from 'react-query';
|
||||
import {
|
||||
getGetTraceThreadQueryKey,
|
||||
getTraceThread,
|
||||
} from 'api/generated/services/tracedetail';
|
||||
import type { GetTraceThreadParams } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ThreadSpan } from '../types';
|
||||
import { isNotFoundError, toThreadSpan } from '../utils';
|
||||
|
||||
const THREAD_PAGE_LIMIT = 100;
|
||||
const MAX_RETRIES = 3;
|
||||
const STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
type PageParam = Pick<GetTraceThreadParams, 'after' | 'before'>;
|
||||
|
||||
interface UseTraceThreadOptions {
|
||||
traceId: string;
|
||||
interestedSpanId?: string;
|
||||
}
|
||||
|
||||
interface UseTraceThreadResult {
|
||||
spans: ThreadSpan[];
|
||||
spansAboveFirstPage: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function useTraceThread({
|
||||
traceId,
|
||||
interestedSpanId,
|
||||
}: UseTraceThreadOptions): UseTraceThreadResult {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: getGetTraceThreadQueryKey(
|
||||
{ traceID: traceId },
|
||||
{ spanId: interestedSpanId },
|
||||
),
|
||||
queryFn: ({ pageParam, signal }) => {
|
||||
const { after, before } = (pageParam ?? {}) as PageParam;
|
||||
return getTraceThread(
|
||||
{ traceID: traceId },
|
||||
{
|
||||
limit: THREAD_PAGE_LIMIT,
|
||||
after,
|
||||
before,
|
||||
// The interested span only shapes the first page; cursors drive the rest.
|
||||
spanId: after || before ? undefined : interestedSpanId,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage): PageParam | undefined =>
|
||||
lastPage.data.nextCursor ? { after: lastPage.data.nextCursor } : undefined,
|
||||
getPreviousPageParam: (firstPage): PageParam | undefined =>
|
||||
firstPage.data.prevCursor
|
||||
? { before: firstPage.data.prevCursor }
|
||||
: undefined,
|
||||
// An unknown interested span is a 404; retrying won't find it.
|
||||
retry: (failureCount, error): boolean =>
|
||||
!isNotFoundError(error) && failureCount < MAX_RETRIES,
|
||||
enabled: !!traceId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: STALE_TIME_MS,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() =>
|
||||
query.data?.pages.flatMap((page) => page.data.spans.map(toThreadSpan)) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
|
||||
const spansAboveFirstPage = useMemo(
|
||||
() =>
|
||||
query.data?.pages.reduce(
|
||||
(count, page, index) =>
|
||||
(query.data.pageParams[index] as PageParam | undefined)?.before
|
||||
? count + page.data.spans.length
|
||||
: count,
|
||||
0,
|
||||
) ?? 0,
|
||||
[query.data],
|
||||
);
|
||||
|
||||
return {
|
||||
spans,
|
||||
spansAboveFirstPage,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isFetchingPreviousPage: query.isFetchingPreviousPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
fetchPreviousPage: query.fetchPreviousPage,
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
|
||||
export type ThreadSpan = SpanV3 & {
|
||||
formattedInput?: ThreadMessage[];
|
||||
formattedOutput?: ThreadMessage[];
|
||||
};
|
||||
|
||||
export enum ThreadView {
|
||||
Formatted = 'formatted',
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
export enum InterestedSpanStatus {
|
||||
None = 'none',
|
||||
Loading = 'loading',
|
||||
Found = 'found',
|
||||
// Backend anchors on the span's position but drops it for lacking messages.
|
||||
NoMessages = 'noMessages',
|
||||
NotFound = 'notFound',
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isAxiosError } from 'axios';
|
||||
|
||||
import { collectToolCallKeys } from '../AIThreadMessage/utils';
|
||||
import { InterestedSpanStatus, ThreadSpan } from './types';
|
||||
|
||||
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
|
||||
export function toThreadSpan(span: SpantypesThreadSpanDTO): ThreadSpan {
|
||||
return {
|
||||
...(span as unknown as ThreadSpan),
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNotFoundError(error: unknown): boolean {
|
||||
return isAxiosError(error) && error.response?.status === 404;
|
||||
}
|
||||
|
||||
export function buildToolCallIndex(spans: ThreadSpan[]): Set<string> {
|
||||
return new Set(
|
||||
spans.flatMap((span) => [
|
||||
...collectToolCallKeys(span.formattedInput ?? []),
|
||||
...collectToolCallKeys(span.formattedOutput ?? []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInterestedSpanStatus(
|
||||
interestedSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
): InterestedSpanStatus {
|
||||
if (!interestedSpanId) {
|
||||
return InterestedSpanStatus.None;
|
||||
}
|
||||
if (isLoading) {
|
||||
return InterestedSpanStatus.Loading;
|
||||
}
|
||||
if (spans.length === 0) {
|
||||
return InterestedSpanStatus.NotFound;
|
||||
}
|
||||
return spans.some((span) => span.span_id === interestedSpanId)
|
||||
? InterestedSpanStatus.Found
|
||||
: InterestedSpanStatus.NoMessages;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -1,6 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||
|
||||
import styles from './TraceDetailsWrapper.module.scss';
|
||||
|
||||
interface TraceDetailsWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceDetailsWrapper({
|
||||
children,
|
||||
}: TraceDetailsWrapperProps): JSX.Element {
|
||||
return (
|
||||
<TraceStoreSync>
|
||||
<div className={styles.root}>{children}</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsWrapper;
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
@@ -31,6 +30,7 @@ function TraceFlamegraph({
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
@@ -42,14 +42,14 @@ function TraceFlamegraph({
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history],
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
@@ -12,7 +12,6 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -164,11 +163,12 @@ function Filters({
|
||||
const { selectedCategory, categories, handleCategoryChange } =
|
||||
useSpanCategoryFilter(filterProps);
|
||||
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
@@ -177,7 +177,7 @@ function Filters({
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history],
|
||||
[filteredSpanIds, history, search],
|
||||
);
|
||||
|
||||
const isFilterActive = committedExpression.trim().length > 0;
|
||||
|
||||
@@ -25,7 +25,7 @@ import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -676,19 +676,19 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: SpanV3): void => {
|
||||
setSelectedSpan(span);
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
if (span?.span_id) {
|
||||
params.set('spanId', span?.span_id);
|
||||
urlQuery.set('spanId', span?.span_id);
|
||||
}
|
||||
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
},
|
||||
[setSelectedSpan, safeNavigate],
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
// Left side columns using TanStack React Table (extensible for future columns)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// Local identity-proxy mock for this module so `styles.foo` resolves to
|
||||
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
|
||||
@@ -230,11 +226,6 @@ describe('Span Click User Flows', () => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
@@ -269,6 +260,7 @@ describe('Span Click User Flows', () => {
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
@@ -436,6 +428,10 @@ describe('Span Click User Flows', () => {
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringMatching(
|
||||
@@ -445,41 +441,4 @@ describe('Span Click User Flows', () => {
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a tab param the router has not seen when selecting spans', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// nuqs wrote ?tab=thread to the real URL; react-router's copy is stale.
|
||||
__setSearchParamsGetterForTest(() => new URLSearchParams('tab=thread'));
|
||||
|
||||
renderWithTraceProvider(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
interestedSpanId={{ spanId: '', isUncollapsed: false }}
|
||||
uncollapsedNodes={mockSpans.map((s) => s.span_id)}
|
||||
isFullDataLoaded={false}
|
||||
localUncollapsedNodes={new Set()}
|
||||
setLocalUncollapsedNodes={jest.fn()}
|
||||
setInterestedSpanId={jest.fn()}
|
||||
selectedSpan={undefined}
|
||||
setSelectedSpan={jest.fn()}
|
||||
filteredSpanIds={[]}
|
||||
isFilterActive={false}
|
||||
/>,
|
||||
undefined,
|
||||
{ initialRoute: '/trace' },
|
||||
);
|
||||
|
||||
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
|
||||
const spanElement = spanOverview.querySelector(
|
||||
SPAN_OVERVIEW_CLASS,
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{ search: expect.stringMatching(/tab=thread.*spanId=span-1/) },
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,11 +53,3 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
|
||||
export enum TraceDetailsTab {
|
||||
Overview = 'overview',
|
||||
Thread = 'thread',
|
||||
}
|
||||
|
||||
export const LLM_OBSERVABILITY_DOCS_URL =
|
||||
'https://signoz.io/docs/llm-observability/';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
|
||||
/** Span details position, persisted under the same key Overview uses. */
|
||||
export function useSpanDetailVariant(): [
|
||||
SpanDetailVariant,
|
||||
(variant: SpanDetailVariant) => void,
|
||||
] {
|
||||
const [variant, setVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const changeVariant = useCallback((next: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
|
||||
setVariant(next);
|
||||
}, []);
|
||||
|
||||
return [variant, changeVariant];
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
|
||||
export function useTraceDetailsTab(): UseQueryStateReturn<
|
||||
TraceDetailsTab,
|
||||
TraceDetailsTab
|
||||
> {
|
||||
return useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
|
||||
TraceDetailsTab.Overview,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailsTab } from './constants';
|
||||
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import {
|
||||
SPAN_DETAILS_PANEL_MIN_WIDTH,
|
||||
SpanDetailVariant,
|
||||
} from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
|
||||
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
|
||||
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from './utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||
const [hasTraceData, setHasTraceData] = useState(false);
|
||||
const dataLoadedFiredForRef = useRef<string>('');
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -23,31 +94,421 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
// The filter query lives in Filters' local state, which unmounts with the
|
||||
// Overview tab; drop its result too so both start empty on return.
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
if (!isOverview) {
|
||||
handleFilteredSpansChange([], false);
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
}, [isOverview, handleFilteredSpansChange]);
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = SPAN_DETAILS_PANEL_MIN_WIDTH;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<TraceDetailsWrapper>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={hasTraceData}
|
||||
/>
|
||||
{isOverview ? (
|
||||
<TraceDetailsOverview
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onHasTraceDataChange={setHasTraceData}
|
||||
dataLoadedFiredForRef={dataLoadedFiredForRef}
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={
|
||||
!!traceData?.payload?.spans?.length && !showNoData
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,28 +4,36 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreCallbacks,
|
||||
setTraceStoreUserPreferences,
|
||||
} from './traceStore';
|
||||
|
||||
interface TraceStoreSyncProps {
|
||||
availableColorByFields: string[] | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges React-managed inputs (`userPreferences` from AppContext and the
|
||||
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
|
||||
* is pushed by TraceDetailsOverview, which owns the span data.
|
||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
||||
*
|
||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||
* paints with the default colour first and then swaps to the user's
|
||||
* persisted choice. AppProvider fires the prefs query as soon as the user
|
||||
* is logged in, so this gate is usually already settled by mount time.
|
||||
*/
|
||||
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
|
||||
function TraceStoreSync({
|
||||
availableColorByFields,
|
||||
children,
|
||||
}: TraceStoreSyncProps): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -37,7 +37,7 @@ interface TraceStoreState {
|
||||
availableColorByOptions: ColorByOption[];
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||
setCallbacks: (callbacks: {
|
||||
|
||||
@@ -11,8 +11,6 @@ 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 {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { parseJsonObject } from '../AIThreadMessage/utils';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadSpan } from '../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from './aiTraceUtils';
|
||||
|
||||
/** Parsed JSON, or the original string when it isn't JSON. */
|
||||
export type RawMessagesValue = object | string;
|
||||
|
||||
export interface SpanMessagesData {
|
||||
formattedInput: ThreadMessage[];
|
||||
formattedOutput: ThreadMessage[];
|
||||
rawInput?: RawMessagesValue;
|
||||
rawOutput?: RawMessagesValue;
|
||||
}
|
||||
|
||||
function toRawValue(value: unknown): RawMessagesValue | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return parseJsonObject(value) ?? value;
|
||||
}
|
||||
return typeof value === 'object' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
|
||||
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
|
||||
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
|
||||
|
||||
// Spans from before the JSON column have no formatted messages.
|
||||
return {
|
||||
formattedInput: span.formattedInput ?? [],
|
||||
formattedOutput: span.formattedOutput ?? [],
|
||||
rawInput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFormattedMessages(data: SpanMessagesData): boolean {
|
||||
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
|
||||
}
|
||||
|
||||
export function hasRawMessages(data: SpanMessagesData): boolean {
|
||||
return !!data.rawInput || !!data.rawOutput;
|
||||
}
|
||||
|
||||
export function hasAnyMessages(data: SpanMessagesData): boolean {
|
||||
return hasFormattedMessages(data) || hasRawMessages(data);
|
||||
}
|
||||
@@ -3,10 +3,13 @@ package contextlinks
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
@@ -52,40 +55,66 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
|
||||
return params
|
||||
}
|
||||
|
||||
// BuilderQueryForSignal returns the filter expression and group-by keys of the
|
||||
// builder query for the given signal, or found=false when the composite query
|
||||
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
|
||||
// queries (builder_ai_query) count as trace builder queries.
|
||||
// TODO(srikanthccv): re-visit this and support multiple queries.
|
||||
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
|
||||
case telemetrytypes.SignalTraces:
|
||||
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
|
||||
func PrepareRuleFilterExpression(condition *ruletypes.RuleCondition, signal telemetrytypes.Signal, labels map[string]string) (string, bool, error) {
|
||||
var expressions []string
|
||||
if condition != nil && condition.CompositeQuery != nil {
|
||||
names, err := resolveSelectedQueryNames(condition)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
switch signal {
|
||||
case telemetrytypes.SignalLogs:
|
||||
expressions = prepareFilterExpressions[qbtypes.LogAggregation](condition.CompositeQuery.Queries, names, signal, labels)
|
||||
case telemetrytypes.SignalTraces:
|
||||
expressions = prepareFilterExpressions[qbtypes.TraceAggregation](condition.CompositeQuery.Queries, names, signal, labels)
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
|
||||
switch {
|
||||
case len(expressions) == 0:
|
||||
// a ClickHouse SQL alert has no builder query; its link filters on the labels alone
|
||||
return PrepareFilterExpression(labels, "", nil), false, nil
|
||||
case len(expressions) == 1:
|
||||
return expressions[0], true, nil
|
||||
case slices.Contains(expressions, ""):
|
||||
// an unfiltered formula input matches everything, so the merged filter does too
|
||||
return "", true, nil
|
||||
}
|
||||
// the explorer list views render one query only, so a formula's inputs merge into one filter
|
||||
return "(" + strings.Join(expressions, ") OR (") + ")", true, nil
|
||||
}
|
||||
|
||||
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
|
||||
var q qbtypes.QueryBuilderQuery[T]
|
||||
found := false
|
||||
func resolveSelectedQueryNames(condition *ruletypes.RuleCondition) ([]string, error) {
|
||||
selected := condition.SelectedQueryName()
|
||||
for _, query := range condition.CompositeQuery.Queries {
|
||||
if query.GetQueryName() != selected {
|
||||
continue
|
||||
}
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok {
|
||||
return formula.ExtractReferencedQueryNames()
|
||||
}
|
||||
return []string{selected}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func prepareFilterExpressions[T any](queries []qbtypes.QueryEnvelope, names []string, signal telemetrytypes.Signal, labels map[string]string) []string {
|
||||
var expressions []string
|
||||
for _, query := range queries {
|
||||
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
|
||||
continue
|
||||
}
|
||||
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
|
||||
q = spec
|
||||
found = true
|
||||
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T])
|
||||
// spec.Disabled is deliberately not checked: a formula's inputs are
|
||||
// usually hidden in the UI but must still make it into the link
|
||||
if !ok || spec.Signal != signal || !slices.Contains(names, spec.Name) {
|
||||
continue
|
||||
}
|
||||
filterExpr := ""
|
||||
if spec.Filter != nil {
|
||||
filterExpr = spec.Filter.Expression
|
||||
}
|
||||
expressions = append(expressions, PrepareFilterExpression(labels, filterExpr, spec.GroupBy))
|
||||
}
|
||||
if !found || q.Signal != signal {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if q.Filter != nil {
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
return filterExpr, q.GroupBy, true
|
||||
return expressions
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuilderQueryForSignal(t *testing.T) {
|
||||
func TestPrepareRuleFilterExpression(t *testing.T) {
|
||||
logQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
@@ -19,62 +20,205 @@ func TestBuilderQueryForSignal(t *testing.T) {
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
},
|
||||
}
|
||||
hiddenLogQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Disabled: true,
|
||||
Filter: &qbtypes.Filter{Expression: "body CONTAINS 'timeout'"},
|
||||
},
|
||||
}
|
||||
unfilteredLogQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
},
|
||||
}
|
||||
traceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Name: "D",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}
|
||||
promQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypePromQL,
|
||||
Spec: qbtypes.PromQuery{Name: "C"},
|
||||
Spec: qbtypes.PromQuery{Name: "E"},
|
||||
}
|
||||
aiTraceQuery := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "D",
|
||||
Name: "F",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
|
||||
},
|
||||
}
|
||||
errorRateFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F1", Expression: "B / A * 100"},
|
||||
}
|
||||
unfilteredFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F2", Expression: "A / C"},
|
||||
}
|
||||
brokenFormula := qbtypes.QueryEnvelope{
|
||||
Type: qbtypes.QueryTypeFormula,
|
||||
Spec: qbtypes.QueryBuilderFormula{Name: "F3", Expression: "A +"},
|
||||
}
|
||||
|
||||
t.Run("logs query among mixed queries", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "service.name", groupBy[0].Name)
|
||||
})
|
||||
testCases := []struct {
|
||||
name string
|
||||
condition *ruletypes.RuleCondition
|
||||
signal telemetrytypes.Signal
|
||||
labels map[string]string
|
||||
expectedError bool
|
||||
expectedExpression string
|
||||
expectedFound bool
|
||||
}{
|
||||
{
|
||||
name: "SelectedLogsQuery_AmongMixedQueries",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedLogsQuery_LeavesOutUnselectedQuery",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "(severity_text='ERROR') AND service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_MergesHiddenInputsInCompositeQueryOrder",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F1",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery, logQuery, errorRateFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "((body CONTAINS 'timeout') AND service.name='checkout') OR ((severity_text='ERROR') AND service.name='checkout')",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_UnfilteredInputMatchesEverything",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F2",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, unfilteredLogQuery, unfilteredFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedFormula_InvalidExpression",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F3",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, brokenFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedError: true,
|
||||
},
|
||||
{
|
||||
name: "NoSelectedQuery_FallsBackToFormula",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, hiddenLogQuery, errorRateFormula}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "((severity_text='ERROR') AND service.name='checkout') OR ((body CONTAINS 'timeout') AND service.name='checkout')",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "NoSelectedQuery_OnlyHiddenQueries",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{hiddenLogQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "SelectedTracesQuery_WithoutFilter",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "D",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedAITraceQuery_CountsAsTraces",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "F",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, aiTraceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalTraces,
|
||||
labels: map[string]string{"session.id": "s1"},
|
||||
expectedExpression: "(trace.input_tokens>1000) AND session.id='s1'",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SelectedQuery_OfAnotherSignal_LabelsOnly",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "D",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery, traceQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "SelectedQuery_NotABuilderQuery",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "E",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{promQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalLogs,
|
||||
labels: map[string]string{},
|
||||
expectedExpression: "",
|
||||
expectedFound: false,
|
||||
},
|
||||
{
|
||||
name: "UnsupportedSignal_LabelsOnly",
|
||||
condition: &ruletypes.RuleCondition{
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{Queries: []qbtypes.QueryEnvelope{logQuery}},
|
||||
},
|
||||
signal: telemetrytypes.SignalMetrics,
|
||||
labels: map[string]string{"service.name": "checkout"},
|
||||
expectedExpression: "service.name='checkout'",
|
||||
expectedFound: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("traces query without filter", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Empty(t, filterExpr)
|
||||
assert.Empty(t, groupBy)
|
||||
})
|
||||
|
||||
t.Run("ai trace query counts as traces", func(t *testing.T) {
|
||||
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
|
||||
require.Len(t, groupBy, 1)
|
||||
assert.Equal(t, "session.id", groupBy[0].Name)
|
||||
})
|
||||
|
||||
t.Run("no builder query for signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("no builder queries at all", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
|
||||
assert.False(t, found)
|
||||
})
|
||||
|
||||
t.Run("unsupported signal", func(t *testing.T) {
|
||||
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
|
||||
assert.False(t, found)
|
||||
})
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
expression, found, err := PrepareRuleFilterExpression(testCase.condition, testCase.signal, testCase.labels)
|
||||
if testCase.expectedError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedExpression, expression)
|
||||
assert.Equal(t, testCase.expectedFound, found)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package implrulestatehistory
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
@@ -18,9 +20,10 @@ import (
|
||||
// explorer with the context that produced it.
|
||||
type relatedLinkBuilder struct {
|
||||
alertType ruletypes.AlertType
|
||||
signal telemetrytypes.Signal
|
||||
evaluation ruletypes.Evaluation
|
||||
filterExpr string
|
||||
groupBy []qbtypes.GroupByKey
|
||||
condition *ruletypes.RuleCondition
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// relatedLinkBuilderForRule returns nil when the rule cannot be loaded or is
|
||||
@@ -49,7 +52,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
builder := &relatedLinkBuilder{alertType: rule.AlertType}
|
||||
builder := &relatedLinkBuilder{alertType: rule.AlertType, signal: signal, condition: rule.RuleCondition, logger: m.logger.With(slog.String("rule.id", ruleID))}
|
||||
if rule.Evaluation != nil {
|
||||
if evaluation, err := rule.Evaluation.GetEvaluation(); err == nil {
|
||||
builder.evaluation = evaluation
|
||||
@@ -63,10 +66,6 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
}
|
||||
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
@@ -83,13 +82,19 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
|
||||
|
||||
// links returns the explorer query params for the given entry labels and time
|
||||
// range.
|
||||
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
|
||||
func (b *relatedLinkBuilder) links(ctx context.Context, labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
|
||||
lbls := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
|
||||
// links are still built from the labels alone when the rule has no builder
|
||||
// query for the signal (e.g. ClickHouse SQL alerts)
|
||||
whereClause, _, err := contextlinks.PrepareRuleFilterExpression(b.condition, b.signal, lbls)
|
||||
if err != nil {
|
||||
b.logger.ErrorContext(ctx, "failed to prepare the filter for related links", errors.Attr(err))
|
||||
return rulestatehistorytypes.RelatedLinks{}
|
||||
}
|
||||
|
||||
switch b.alertType {
|
||||
case ruletypes.AlertTypeLogs:
|
||||
|
||||
@@ -2,6 +2,7 @@ package implrulestatehistory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
@@ -15,10 +16,11 @@ import (
|
||||
type module struct {
|
||||
store rulestatehistorytypes.Store
|
||||
ruleStore ruletypes.RuleStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore) rulestatehistory.Module {
|
||||
return &module{store: store, ruleStore: ruleStore}
|
||||
func NewModule(store rulestatehistorytypes.Store, ruleStore ruletypes.RuleStore, logger *slog.Logger) rulestatehistory.Module {
|
||||
return &module{store: store, ruleStore: ruleStore, logger: logger}
|
||||
}
|
||||
|
||||
func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]rulestatehistorytypes.RuleStateHistory, error) {
|
||||
@@ -34,7 +36,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
|
||||
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
|
||||
for idx := range items {
|
||||
start, end := builder.queryWindow(items[idx].UnixMilli)
|
||||
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
|
||||
items[idx].RelatedLinks = builder.links(ctx, items[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +62,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
|
||||
// span it too instead of a single evaluation window
|
||||
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
|
||||
for idx := range contributors {
|
||||
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
|
||||
contributors[idx].RelatedLinks = builder.links(ctx, contributors[idx].Labels, start, end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,7 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -95,24 +93,19 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalLogs, resultLabels.Map())
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "failed to prepare the filter for the logs link", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalLogs)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -120,18 +113,15 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
whereClause, found, err := contextlinks.PrepareRuleFilterExpression(r.ruleCondition, telemetrytypes.SignalTraces, resultLabels.Map())
|
||||
if err != nil {
|
||||
r.logger.ErrorContext(ctx, "failed to prepare the filter for the traces link", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr, groupBy, found := contextlinks.BuilderQueryForSignal(r.ruleCondition.CompositeQuery.Queries, telemetrytypes.SignalTraces)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
|
||||
|
||||
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ func NewModules(
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore, providerSettings.Logger),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
|
||||
@@ -131,6 +131,22 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f QueryBuilderFormula) ExtractReferencedQueryNames() ([]string, error) {
|
||||
// parsed the way the evaluator parses it, so the names match what it resolves
|
||||
expression, err := govaluate.NewEvaluableExpressionWithFunctions(f.Expression, EvalFuncs())
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse expression for formula query %q", f.Name)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, variable := range expression.Vars() {
|
||||
if !slices.Contains(names, variable) {
|
||||
names = append(names, variable)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// small container to store the query name and index or alias reference
|
||||
// for a variable in the formula expression
|
||||
// read below for more details on aggregation references.
|
||||
|
||||
@@ -126,6 +126,12 @@ type RuleCondition struct {
|
||||
}
|
||||
|
||||
func (rc *RuleCondition) SelectedQueryName() string {
|
||||
if rc.SelectedQuery != "" {
|
||||
return rc.SelectedQuery
|
||||
}
|
||||
if rc.CompositeQuery == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
queryNames := map[string]struct{}{}
|
||||
|
||||
@@ -169,6 +175,9 @@ func (rc *RuleCondition) SelectedQueryName() string {
|
||||
for k := range queryNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[len(keys)-1]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
104
tests/integration/testdata/ruler/test_scenarios/rule_state_history_formula_logs/rule.json
vendored
Normal file
104
tests/integration/testdata/ruler/test_scenarios/rule_state_history_formula_logs/rule.json
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"alert": "rule_state_history_formula_logs",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 0,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "body CONTAINS 'payment success'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"disabled": true,
|
||||
"filter": {
|
||||
"expression": "severity_text = 'INFO'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_formula",
|
||||
"spec": {
|
||||
"name": "F1",
|
||||
"expression": "A / B * 100"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "F1"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment success", "severity_text": "INFO" }
|
||||
88
tests/integration/testdata/ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json
vendored
Normal file
88
tests/integration/testdata/ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"alert": "rule_state_history_multi_query_logs",
|
||||
"ruleType": "threshold_rule",
|
||||
"alertType": "LOGS_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 0,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "builder",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"filter": {
|
||||
"expression": "body CONTAINS 'payment success'"
|
||||
},
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "service.name",
|
||||
"fieldContext": "resource",
|
||||
"fieldDataType": "string"
|
||||
}
|
||||
],
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"filter": {
|
||||
"expression": "severity_text = 'INFO'"
|
||||
},
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
@@ -151,3 +151,81 @@ def test_ai_traces_rule_history_related_links(
|
||||
assert contributor_link["end"] == query_end_ms * 1_000_000
|
||||
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
|
||||
|
||||
|
||||
def test_multi_query_logs_rule_history_related_links(
|
||||
signoz: types.SigNoz,
|
||||
create_alert_rule_with_channel: Callable[[str], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_multi_query_logs/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_multi_query_logs/rule.json")
|
||||
|
||||
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
|
||||
|
||||
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
|
||||
assert item.get("relatedLogsLink", "") != ""
|
||||
|
||||
# the rule evaluates query A alone, so query B plays no part in the alert
|
||||
# and its filter stays out of the link
|
||||
link = parse_related_link(item["relatedLogsLink"])
|
||||
assert link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND service.name='payment-service'"}}]
|
||||
|
||||
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
|
||||
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
|
||||
assert len(contributors) == 1
|
||||
assert contributors[0].get("relatedLogsLink", "") != ""
|
||||
|
||||
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"] == [{"dataSource": "logs", "filter": {"expression": "(body CONTAINS 'payment success') AND service.name='payment-service'"}}]
|
||||
|
||||
|
||||
def test_formula_logs_rule_history_related_links(
|
||||
signoz: types.SigNoz,
|
||||
create_alert_rule_with_channel: Callable[[str], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
|
||||
|
||||
insert_alert_data(
|
||||
[types.AlertData(type="logs", data_path="ruler/test_scenarios/rule_state_history_formula_logs/alert_data.jsonl")],
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
rule_id = create_alert_rule_with_channel("ruler/test_scenarios/rule_state_history_formula_logs/rule.json")
|
||||
|
||||
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
|
||||
|
||||
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
|
||||
assert item.get("relatedLogsLink", "") != ""
|
||||
|
||||
# the rule evaluates formula F1 over the hidden queries A and B, so the
|
||||
# link's one query ORs both filters, each narrowed to the firing series' labels
|
||||
link = parse_related_link(item["relatedLogsLink"])
|
||||
assert link["composite_query"]["builder"]["queryData"] == [
|
||||
{
|
||||
"dataSource": "logs",
|
||||
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND service.name='payment-service')"},
|
||||
}
|
||||
]
|
||||
|
||||
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
|
||||
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "payment-service"]
|
||||
assert len(contributors) == 1
|
||||
assert contributors[0].get("relatedLogsLink", "") != ""
|
||||
|
||||
contributor_link = parse_related_link(contributors[0]["relatedLogsLink"])
|
||||
assert contributor_link["composite_query"]["builder"]["queryData"] == [
|
||||
{
|
||||
"dataSource": "logs",
|
||||
"filter": {"expression": "((body CONTAINS 'payment success') AND service.name='payment-service') OR ((severity_text='INFO') AND service.name='payment-service')"},
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user