mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-18 07:56:56 +00:00
feat: add support for span logs (#8857)
This commit is contained in:
parent
4851527840
commit
6c57735a81
@ -44,6 +44,7 @@
|
|||||||
"@sentry/react": "8.41.0",
|
"@sentry/react": "8.41.0",
|
||||||
"@sentry/webpack-plugin": "2.22.6",
|
"@sentry/webpack-plugin": "2.22.6",
|
||||||
"@signozhq/badge": "0.0.2",
|
"@signozhq/badge": "0.0.2",
|
||||||
|
"@signozhq/button": "0.0.2",
|
||||||
"@signozhq/calendar": "0.0.0",
|
"@signozhq/calendar": "0.0.0",
|
||||||
"@signozhq/callout": "0.0.2",
|
"@signozhq/callout": "0.0.2",
|
||||||
"@signozhq/design-tokens": "1.1.4",
|
"@signozhq/design-tokens": "1.1.4",
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import './RawLogView.styles.scss';
|
import { Color } from '@signozhq/design-tokens';
|
||||||
|
import { DrawerProps, Tooltip } from 'antd';
|
||||||
import { DrawerProps } from 'antd';
|
|
||||||
import LogDetail from 'components/LogDetail';
|
import LogDetail from 'components/LogDetail';
|
||||||
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
@ -26,7 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
|
|||||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||||
// styles
|
// styles
|
||||||
import { RawLogContent, RawLogViewContainer } from './styles';
|
import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles';
|
||||||
import { RawLogViewProps } from './types';
|
import { RawLogViewProps } from './types';
|
||||||
|
|
||||||
function RawLogView({
|
function RawLogView({
|
||||||
@ -35,12 +34,17 @@ function RawLogView({
|
|||||||
data,
|
data,
|
||||||
linesPerRow,
|
linesPerRow,
|
||||||
isTextOverflowEllipsisDisabled,
|
isTextOverflowEllipsisDisabled,
|
||||||
|
isHighlighted,
|
||||||
|
helpTooltip,
|
||||||
selectedFields = [],
|
selectedFields = [],
|
||||||
fontSize,
|
fontSize,
|
||||||
|
onLogClick,
|
||||||
}: RawLogViewProps): JSX.Element {
|
}: RawLogViewProps): JSX.Element {
|
||||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
const {
|
||||||
data.id,
|
isHighlighted: isUrlHighlighted,
|
||||||
);
|
isLogsExplorerPage,
|
||||||
|
onLogCopy,
|
||||||
|
} = useCopyLogLink(data.id);
|
||||||
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -126,12 +130,20 @@ function RawLogView({
|
|||||||
formatTimezoneAdjustedTimestamp,
|
formatTimezoneAdjustedTimestamp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleClickExpand = useCallback(() => {
|
const handleClickExpand = useCallback(
|
||||||
|
(event: MouseEvent) => {
|
||||||
if (activeContextLog || isReadOnly) return;
|
if (activeContextLog || isReadOnly) return;
|
||||||
|
|
||||||
|
// Use custom click handler if provided, otherwise use default behavior
|
||||||
|
if (onLogClick) {
|
||||||
|
onLogClick(data, event);
|
||||||
|
} else {
|
||||||
onSetActiveLog(data);
|
onSetActiveLog(data);
|
||||||
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
||||||
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
|
}
|
||||||
|
},
|
||||||
|
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
|
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
|
||||||
(
|
(
|
||||||
@ -183,10 +195,11 @@ function RawLogView({
|
|||||||
align="middle"
|
align="middle"
|
||||||
$isDarkMode={isDarkMode}
|
$isDarkMode={isDarkMode}
|
||||||
$isReadOnly={isReadOnly}
|
$isReadOnly={isReadOnly}
|
||||||
$isHightlightedLog={isHighlighted}
|
$isHightlightedLog={isUrlHighlighted}
|
||||||
$isActiveLog={
|
$isActiveLog={
|
||||||
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
|
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
|
||||||
}
|
}
|
||||||
|
$isCustomHighlighted={isHighlighted}
|
||||||
$logType={logType}
|
$logType={logType}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
@ -197,6 +210,15 @@ function RawLogView({
|
|||||||
severityText={data.severity_text}
|
severityText={data.severity_text}
|
||||||
severityNumber={data.severity_number}
|
severityNumber={data.severity_number}
|
||||||
/>
|
/>
|
||||||
|
{helpTooltip && (
|
||||||
|
<Tooltip title={helpTooltip} placement="top" mouseEnterDelay={0.5}>
|
||||||
|
<InfoIconWrapper
|
||||||
|
size={14}
|
||||||
|
className="help-tooltip-icon"
|
||||||
|
color={Color.BG_VANILLA_400}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<RawLogContent
|
<RawLogContent
|
||||||
className="raw-log-content"
|
className="raw-log-content"
|
||||||
@ -240,6 +262,7 @@ RawLogView.defaultProps = {
|
|||||||
isActiveLog: false,
|
isActiveLog: false,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
isTextOverflowEllipsisDisabled: false,
|
isTextOverflowEllipsisDisabled: false,
|
||||||
|
isHighlighted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RawLogView;
|
export default RawLogView;
|
||||||
|
|||||||
@ -3,8 +3,13 @@ import { blue } from '@ant-design/colors';
|
|||||||
import { Color } from '@signozhq/design-tokens';
|
import { Color } from '@signozhq/design-tokens';
|
||||||
import { Col, Row, Space } from 'antd';
|
import { Col, Row, Space } from 'antd';
|
||||||
import { FontSize } from 'container/OptionsMenu/types';
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
|
import {
|
||||||
|
getActiveLogBackground,
|
||||||
|
getCustomHighlightBackground,
|
||||||
|
getDefaultLogBackground,
|
||||||
|
} from 'utils/logs';
|
||||||
|
|
||||||
import { RawLogContentProps } from './types';
|
import { RawLogContentProps } from './types';
|
||||||
|
|
||||||
@ -13,6 +18,7 @@ export const RawLogViewContainer = styled(Row)<{
|
|||||||
$isReadOnly?: boolean;
|
$isReadOnly?: boolean;
|
||||||
$isActiveLog?: boolean;
|
$isActiveLog?: boolean;
|
||||||
$isHightlightedLog: boolean;
|
$isHightlightedLog: boolean;
|
||||||
|
$isCustomHighlighted?: boolean;
|
||||||
$logType: string;
|
$logType: string;
|
||||||
fontSize: FontSize;
|
fontSize: FontSize;
|
||||||
}>`
|
}>`
|
||||||
@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{
|
|||||||
};
|
};
|
||||||
transition: background-color 2s ease-in;`
|
transition: background-color 2s ease-in;`
|
||||||
: ''}
|
: ''}
|
||||||
|
|
||||||
|
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||||
|
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const InfoIconWrapper = styled(Info)`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: auto;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ExpandIconWrapper = styled(Col)`
|
export const ExpandIconWrapper = styled(Col)`
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { FontSize } from 'container/OptionsMenu/types';
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
import { MouseEvent } from 'react';
|
||||||
import { IField } from 'types/api/logs/fields';
|
import { IField } from 'types/api/logs/fields';
|
||||||
import { ILog } from 'types/api/logs/log';
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
|
||||||
@ -6,10 +7,13 @@ export interface RawLogViewProps {
|
|||||||
isActiveLog?: boolean;
|
isActiveLog?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
isTextOverflowEllipsisDisabled?: boolean;
|
isTextOverflowEllipsisDisabled?: boolean;
|
||||||
|
isHighlighted?: boolean;
|
||||||
|
helpTooltip?: string;
|
||||||
data: ILog;
|
data: ILog;
|
||||||
linesPerRow: number;
|
linesPerRow: number;
|
||||||
fontSize: FontSize;
|
fontSize: FontSize;
|
||||||
selectedFields?: IField[];
|
selectedFields?: IField[];
|
||||||
|
onLogClick?: (log: ILog, event: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawLogContentProps {
|
export interface RawLogContentProps {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
|
|||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string | React.ReactNode;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -83,4 +83,7 @@ export const REACT_QUERY_KEY = {
|
|||||||
// Quick Filters Query Keys
|
// Quick Filters Query Keys
|
||||||
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
|
GET_CUSTOM_FILTERS: 'GET_CUSTOM_FILTERS',
|
||||||
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
|
GET_OTHER_FILTERS: 'GET_OTHER_FILTERS',
|
||||||
|
SPAN_LOGS: 'SPAN_LOGS',
|
||||||
|
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||||
|
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -124,24 +124,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.related-logs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: fit-content;
|
|
||||||
padding: 5px 12px;
|
|
||||||
margin: 10px 12px;
|
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
color: var(--bg-vanilla-400);
|
|
||||||
font-family: Inter;
|
|
||||||
font-size: 14px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 20px; /* 142.857% */
|
|
||||||
letter-spacing: -0.07px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes-events {
|
.attributes-events {
|
||||||
.details-drawer-tabs {
|
.details-drawer-tabs {
|
||||||
.ant-tabs-extra-content {
|
.ant-tabs-extra-content {
|
||||||
@ -268,10 +250,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.related-logs {
|
|
||||||
color: var(--bg-ink-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes-events {
|
.attributes-events {
|
||||||
.details-drawer-tabs {
|
.details-drawer-tabs {
|
||||||
.ant-tabs-nav::before {
|
.ant-tabs-nav::before {
|
||||||
|
|||||||
@ -1,30 +1,28 @@
|
|||||||
import './SpanDetailsDrawer.styles.scss';
|
import './SpanDetailsDrawer.styles.scss';
|
||||||
|
|
||||||
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
|
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
|
||||||
|
import { RadioChangeEvent } from 'antd/lib';
|
||||||
|
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||||
import { QueryParams } from 'constants/query';
|
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||||
import ROUTES from 'constants/routes';
|
|
||||||
import { themeColors } from 'constants/theme';
|
import { themeColors } from 'constants/theme';
|
||||||
import { getTraceToLogsQuery } from 'container/TraceDetail/SelectedSpanDetails/config';
|
|
||||||
import createQueryParams from 'lib/createQueryParams';
|
|
||||||
import history from 'lib/history';
|
|
||||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||||
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
|
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
|
||||||
import { Dispatch, SetStateAction, useState } from 'react';
|
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||||
import { Span } from 'types/api/trace/getTraceV2';
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||||
|
|
||||||
import Attributes from './Attributes/Attributes';
|
import Attributes from './Attributes/Attributes';
|
||||||
|
import { RelatedSignalsViews } from './constants';
|
||||||
import Events from './Events/Events';
|
import Events from './Events/Events';
|
||||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||||
|
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
||||||
|
|
||||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
|
||||||
interface ISpanDetailsDrawerProps {
|
interface ISpanDetailsDrawerProps {
|
||||||
isSpanDetailsDocked: boolean;
|
isSpanDetailsDocked: boolean;
|
||||||
setIsSpanDetailsDocked: Dispatch<SetStateAction<boolean>>;
|
setIsSpanDetailsDocked: Dispatch<SetStateAction<boolean>>;
|
||||||
selectedSpan: Span | undefined;
|
selectedSpan: Span | undefined;
|
||||||
traceID: string;
|
|
||||||
traceStartTime: number;
|
traceStartTime: number;
|
||||||
traceEndTime: number;
|
traceEndTime: number;
|
||||||
}
|
}
|
||||||
@ -35,16 +33,31 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
setIsSpanDetailsDocked,
|
setIsSpanDetailsDocked,
|
||||||
selectedSpan,
|
selectedSpan,
|
||||||
traceStartTime,
|
traceStartTime,
|
||||||
traceID,
|
|
||||||
traceEndTime,
|
traceEndTime,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||||
|
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const [activeDrawerView, setActiveDrawerView] = useState<RelatedSignalsViews>(
|
||||||
|
RelatedSignalsViews.LOGS,
|
||||||
|
);
|
||||||
const color = generateColor(
|
const color = generateColor(
|
||||||
selectedSpan?.serviceName || '',
|
selectedSpan?.serviceName || '',
|
||||||
themeColors.traceDetailColors,
|
themeColors.traceDetailColors,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRelatedSignalsChange = useCallback((e: RadioChangeEvent): void => {
|
||||||
|
const selectedView = e.target.value as RelatedSignalsViews;
|
||||||
|
setActiveDrawerView(selectedView);
|
||||||
|
setIsRelatedSignalsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRelatedSignalsClose = useCallback((): void => {
|
||||||
|
setIsRelatedSignalsOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function getItems(span: Span, startTime: number): TabsProps['items'] {
|
function getItems(span: Span, startTime: number): TabsProps['items'] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -101,19 +114,6 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const onLogsHandler = (): void => {
|
|
||||||
const query = getTraceToLogsQuery(traceID, traceStartTime, traceEndTime);
|
|
||||||
|
|
||||||
history.push(
|
|
||||||
`${ROUTES.LOGS_EXPLORER}?${createQueryParams({
|
|
||||||
[QueryParams.compositeQuery]: JSON.stringify(query),
|
|
||||||
// we subtract 5 minutes from the start time to handle the cases when the trace duration is in nanoseconds
|
|
||||||
[QueryParams.startTime]: traceStartTime - FIVE_MINUTES_IN_MS,
|
|
||||||
// we add 5 minutes to the end time for nano second duration traces
|
|
||||||
[QueryParams.endTime]: traceEndTime + FIVE_MINUTES_IN_MS,
|
|
||||||
})}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -216,12 +216,49 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="item">
|
||||||
|
<Typography.Text className="attribute-key">
|
||||||
|
related signals
|
||||||
|
</Typography.Text>
|
||||||
|
<div className="related-signals-section">
|
||||||
|
<SignozRadioGroup
|
||||||
|
value=""
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div className="view-title">
|
||||||
|
<LogsIcon width={14} height={14} />
|
||||||
|
Logs
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: RelatedSignalsViews.LOGS,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: (
|
||||||
|
// <div className="view-title">
|
||||||
|
// <LogsIcon width={14} height={14} />
|
||||||
|
// Metrics
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// value: RelatedSignalsViews.METRICS,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: (
|
||||||
|
// <div className="view-title">
|
||||||
|
// <Server size={14} />
|
||||||
|
// Infra
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// value: RelatedSignalsViews.INFRA,
|
||||||
|
// },
|
||||||
|
]}
|
||||||
|
onChange={handleRelatedSignalsChange}
|
||||||
|
className="related-signals-radio"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Button onClick={onLogsHandler} className="related-logs">
|
|
||||||
Go to related logs
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<section className="attributes-events">
|
<section className="attributes-events">
|
||||||
<Tabs
|
<Tabs
|
||||||
items={getItems(selectedSpan, traceStartTime)}
|
items={getItems(selectedSpan, traceStartTime)}
|
||||||
@ -240,6 +277,18 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedSpan && (
|
||||||
|
<SpanRelatedSignals
|
||||||
|
selectedSpan={selectedSpan}
|
||||||
|
traceStartTime={traceStartTime}
|
||||||
|
traceEndTime={traceEndTime}
|
||||||
|
isOpen={isRelatedSignalsOpen}
|
||||||
|
onClose={handleRelatedSignalsClose}
|
||||||
|
initialView={activeDrawerView}
|
||||||
|
key={activeDrawerView}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
277
frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx
Normal file
277
frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import './spanLogs.styles.scss';
|
||||||
|
|
||||||
|
import { Button } from '@signozhq/button';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import RawLogView from 'components/Logs/RawLogView';
|
||||||
|
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import {
|
||||||
|
initialQueriesMap,
|
||||||
|
OPERATORS,
|
||||||
|
PANEL_TYPES,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import LogsError from 'container/LogsError/LogsError';
|
||||||
|
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||||
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
|
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||||
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
|
import createQueryParams from 'lib/createQueryParams';
|
||||||
|
import { Compass } from 'lucide-react';
|
||||||
|
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
import {
|
||||||
|
BaseAutocompleteData,
|
||||||
|
DataTypes,
|
||||||
|
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
import { useSpanContextLogs } from './useSpanContextLogs';
|
||||||
|
|
||||||
|
interface SpanLogsProps {
|
||||||
|
traceId: string;
|
||||||
|
spanId: string;
|
||||||
|
timeRange: {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
handleExplorerPageRedirect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpanLogs({
|
||||||
|
traceId,
|
||||||
|
spanId,
|
||||||
|
timeRange,
|
||||||
|
handleExplorerPageRedirect,
|
||||||
|
}: SpanLogsProps): JSX.Element {
|
||||||
|
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||||
|
|
||||||
|
const {
|
||||||
|
logs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
isFetching,
|
||||||
|
isLogSpanRelated,
|
||||||
|
} = useSpanContextLogs({
|
||||||
|
traceId,
|
||||||
|
spanId,
|
||||||
|
timeRange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create trace_id and span_id filters for logs explorer navigation
|
||||||
|
const createLogsFilter = useCallback(
|
||||||
|
(targetSpanId: string): TagFilter => {
|
||||||
|
const traceIdKey: BaseAutocompleteData = {
|
||||||
|
id: uuid(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'trace_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
const spanIdKey: BaseAutocompleteData = {
|
||||||
|
id: uuid(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'span_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(OPERATORS['=']),
|
||||||
|
value: traceId,
|
||||||
|
key: traceIdKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(OPERATORS['=']),
|
||||||
|
value: targetSpanId,
|
||||||
|
key: spanIdKey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[traceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to logs explorer with trace_id and span_id filters
|
||||||
|
const handleLogClick = useCallback(
|
||||||
|
(log: ILog): void => {
|
||||||
|
// Determine if this is a span log or context log
|
||||||
|
const isSpanLog = isLogSpanRelated(log.id);
|
||||||
|
|
||||||
|
// Extract log's span_id (handles both spanID and span_id properties)
|
||||||
|
const logSpanId = log.spanID || log.span_id || '';
|
||||||
|
|
||||||
|
// Use appropriate span ID: current span for span logs, individual log's span for context logs
|
||||||
|
const targetSpanId = isSpanLog ? spanId : logSpanId;
|
||||||
|
const filters = createLogsFilter(targetSpanId);
|
||||||
|
|
||||||
|
// Create base query
|
||||||
|
const baseQuery = updateAllQueriesOperators(
|
||||||
|
initialQueriesMap[DataSource.LOGS],
|
||||||
|
PANEL_TYPES.LIST,
|
||||||
|
DataSource.LOGS,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add appropriate filters to the query
|
||||||
|
const updatedQuery = {
|
||||||
|
...baseQuery,
|
||||||
|
builder: {
|
||||||
|
...baseQuery.builder,
|
||||||
|
queryData: baseQuery.builder.queryData.map((queryData) => ({
|
||||||
|
...queryData,
|
||||||
|
filters,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
[QueryParams.activeLogId]: `"${log.id}"`,
|
||||||
|
[QueryParams.startTime]: timeRange.startTime.toString(),
|
||||||
|
[QueryParams.endTime]: timeRange.endTime.toString(),
|
||||||
|
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;
|
||||||
|
|
||||||
|
window.open(url, '_blank');
|
||||||
|
},
|
||||||
|
[
|
||||||
|
isLogSpanRelated,
|
||||||
|
createLogsFilter,
|
||||||
|
spanId,
|
||||||
|
updateAllQueriesOperators,
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Footer rendering for pagination
|
||||||
|
const hasReachedEndOfLogs = false;
|
||||||
|
|
||||||
|
const getItemContent = useCallback(
|
||||||
|
(_: number, logToRender: ILog): JSX.Element => {
|
||||||
|
const getIsSpanRelated = (log: ILog, currentSpanId: string): boolean => {
|
||||||
|
if (log.spanID) {
|
||||||
|
return log.spanID === currentSpanId;
|
||||||
|
}
|
||||||
|
return log.span_id === currentSpanId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSpanRelated = getIsSpanRelated(logToRender, spanId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RawLogView
|
||||||
|
key={logToRender.id}
|
||||||
|
data={logToRender}
|
||||||
|
linesPerRow={1}
|
||||||
|
fontSize={FontSize.MEDIUM}
|
||||||
|
onLogClick={handleLogClick}
|
||||||
|
isHighlighted={isSpanRelated}
|
||||||
|
helpTooltip={
|
||||||
|
isSpanRelated ? 'This log belongs to the current span' : undefined
|
||||||
|
}
|
||||||
|
selectedFields={[
|
||||||
|
{
|
||||||
|
dataType: 'string',
|
||||||
|
type: '',
|
||||||
|
name: 'body',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataType: 'string',
|
||||||
|
type: '',
|
||||||
|
name: 'timestamp',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[handleLogClick, spanId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFooter = useCallback((): JSX.Element | null => {
|
||||||
|
if (isFetching) {
|
||||||
|
return <div className="logs-loading-skeleton"> Loading more logs ... </div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasReachedEndOfLogs) {
|
||||||
|
return <div className="logs-loading-skeleton"> *** End *** </div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [isFetching, hasReachedEndOfLogs]);
|
||||||
|
|
||||||
|
const renderContent = useMemo(
|
||||||
|
() => (
|
||||||
|
<div className="span-logs-list-container">
|
||||||
|
<PreferenceContextProvider>
|
||||||
|
<OverlayScrollbar isVirtuoso>
|
||||||
|
<Virtuoso
|
||||||
|
className="span-logs-virtuoso"
|
||||||
|
key="span-logs-virtuoso"
|
||||||
|
style={
|
||||||
|
logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}
|
||||||
|
}
|
||||||
|
data={logs}
|
||||||
|
totalCount={logs.length}
|
||||||
|
itemContent={getItemContent}
|
||||||
|
overscan={200}
|
||||||
|
components={{
|
||||||
|
Footer: renderFooter,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OverlayScrollbar>
|
||||||
|
</PreferenceContextProvider>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[logs, getItemContent, renderFooter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNoLogsFound = (): JSX.Element => (
|
||||||
|
<div className="span-logs-empty-content">
|
||||||
|
<section className="description">
|
||||||
|
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
|
||||||
|
<Typography.Text className="no-data-text-1">
|
||||||
|
No logs found for selected span.
|
||||||
|
<span className="no-data-text-2">
|
||||||
|
Try viewing logs for the current trace.
|
||||||
|
</span>
|
||||||
|
</Typography.Text>
|
||||||
|
</section>
|
||||||
|
<section className="action-section">
|
||||||
|
<Button
|
||||||
|
className="action-btn"
|
||||||
|
variant="action"
|
||||||
|
prefixIcon={<Compass size={14} />}
|
||||||
|
onClick={handleExplorerPageRedirect}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
Log Explorer
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
|
||||||
|
{(isLoading || isFetching) && <LogsLoading />}
|
||||||
|
{!isLoading &&
|
||||||
|
!isFetching &&
|
||||||
|
!isError &&
|
||||||
|
logs.length === 0 &&
|
||||||
|
renderNoLogsFound()}
|
||||||
|
{isError && !isLoading && !isFetching && <LogsError />}
|
||||||
|
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpanLogs;
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { Filter } from 'types/api/v5/queryRange';
|
||||||
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
|
import { DataSource } from 'types/common/queryBuilder';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a query payload for fetching logs related to a specific span
|
||||||
|
* @param start - Start time in milliseconds
|
||||||
|
* @param end - End time in milliseconds
|
||||||
|
* @param filter - V5 filter expression for trace_id and span_id
|
||||||
|
* @param order - Timestamp ordering ('desc' for newest first, 'asc' for oldest first)
|
||||||
|
* @returns Query payload for logs API
|
||||||
|
*/
|
||||||
|
export const getSpanLogsQueryPayload = (
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
filter: Filter,
|
||||||
|
order: 'asc' | 'desc' = 'desc',
|
||||||
|
): GetQueryResultsProps => ({
|
||||||
|
graphType: PANEL_TYPES.LIST,
|
||||||
|
selectedTime: 'GLOBAL_TIME',
|
||||||
|
query: {
|
||||||
|
clickhouse_sql: [],
|
||||||
|
promql: [],
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: DataSource.LOGS,
|
||||||
|
queryName: 'A',
|
||||||
|
aggregateOperator: 'noop',
|
||||||
|
aggregateAttribute: {
|
||||||
|
id: '------false',
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
key: '',
|
||||||
|
type: '',
|
||||||
|
},
|
||||||
|
timeAggregation: 'rate',
|
||||||
|
spaceAggregation: 'sum',
|
||||||
|
functions: [],
|
||||||
|
filter,
|
||||||
|
expression: 'A',
|
||||||
|
disabled: false,
|
||||||
|
stepInterval: 60,
|
||||||
|
having: [],
|
||||||
|
limit: null,
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
columnName: 'timestamp',
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupBy: [],
|
||||||
|
legend: '',
|
||||||
|
reduceTo: 'avg',
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
queryTraceOperator: [],
|
||||||
|
},
|
||||||
|
id: uuidv4(),
|
||||||
|
queryType: EQueryType.QUERY_BUILDER,
|
||||||
|
},
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates tag filters for querying logs by trace_id only (for context logs)
|
||||||
|
* @param traceId - The trace identifier
|
||||||
|
* @returns Tag filters for the query builder
|
||||||
|
*/
|
||||||
|
export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: uuidv4(),
|
||||||
|
key: {
|
||||||
|
id: uuidv4(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'trace_id',
|
||||||
|
},
|
||||||
|
op: 'in',
|
||||||
|
value: traceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
});
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
.span-logs {
|
||||||
|
margin-inline: 16px;
|
||||||
|
height: calc(100% - 64px - 55px - 56px);
|
||||||
|
|
||||||
|
&-virtuoso {
|
||||||
|
background: rgba(171, 189, 255, 0.04);
|
||||||
|
}
|
||||||
|
&-list-container .logs-loading-skeleton {
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
border-top: none;
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-empty-content {
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
border-top: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 96px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.description {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 320px;
|
||||||
|
|
||||||
|
.no-data-img {
|
||||||
|
height: 2rem;
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data-text-1 {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
.no-data-text-2 {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
width: 320px;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: var(--bg-slate-500);
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px; /* 142.857% */
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.span-logs {
|
||||||
|
&-empty-content {
|
||||||
|
.description {
|
||||||
|
.no-data-text-1 {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
|
||||||
|
.no-data-text-2 {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
.action-btn {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--bg-vanilla-200);
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
color: var(--bg-ink-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||||
|
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||||
|
import { OPERATORS } from 'constants/queryBuilder';
|
||||||
|
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||||
|
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { Filter } from 'types/api/v5/queryRange';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
import { getSpanLogsQueryPayload } from './constants';
|
||||||
|
|
||||||
|
interface UseSpanContextLogsProps {
|
||||||
|
traceId: string;
|
||||||
|
spanId: string;
|
||||||
|
timeRange: {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseSpanContextLogsReturn {
|
||||||
|
logs: ILog[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
spanLogIds: Set<string>;
|
||||||
|
isLogSpanRelated: (logId: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceIdKey = {
|
||||||
|
id: uuid(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'trace_id',
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Creates v5 filter expression for querying logs by trace_id and span_id (for span logs)
|
||||||
|
*/
|
||||||
|
const createSpanLogsFilters = (traceId: string, spanId: string): Filter => {
|
||||||
|
const spanIdKey = {
|
||||||
|
id: uuid(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'span_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(OPERATORS['=']),
|
||||||
|
value: traceId,
|
||||||
|
key: traceIdKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(OPERATORS['=']),
|
||||||
|
value: spanId,
|
||||||
|
key: spanIdKey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
};
|
||||||
|
|
||||||
|
return convertFiltersToExpression(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates v5 filter expression for querying context logs with id constraints
|
||||||
|
*/
|
||||||
|
const createContextFilters = (
|
||||||
|
traceId: string,
|
||||||
|
logId: string,
|
||||||
|
operator: 'lt' | 'gt',
|
||||||
|
): Filter => {
|
||||||
|
const idKey = {
|
||||||
|
id: uuid(),
|
||||||
|
dataType: DataTypes.String,
|
||||||
|
type: '',
|
||||||
|
key: 'id',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(OPERATORS['=']),
|
||||||
|
value: traceId,
|
||||||
|
key: traceIdKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
op: getOperatorValue(operator === 'lt' ? OPERATORS['<'] : OPERATORS['>']),
|
||||||
|
value: logId,
|
||||||
|
key: idKey,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
op: 'AND',
|
||||||
|
};
|
||||||
|
|
||||||
|
return convertFiltersToExpression(filters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||||
|
export const useSpanContextLogs = ({
|
||||||
|
traceId,
|
||||||
|
spanId,
|
||||||
|
timeRange,
|
||||||
|
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||||
|
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||||
|
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Phase 1: Fetch span-specific logs (trace_id + span_id)
|
||||||
|
const spanFilter = useMemo(() => createSpanLogsFilters(traceId, spanId), [
|
||||||
|
traceId,
|
||||||
|
spanId,
|
||||||
|
]);
|
||||||
|
const spanQueryPayload = useMemo(
|
||||||
|
() =>
|
||||||
|
getSpanLogsQueryPayload(timeRange.startTime, timeRange.endTime, spanFilter),
|
||||||
|
[timeRange.startTime, timeRange.endTime, spanFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: spanData,
|
||||||
|
isLoading: isSpanLoading,
|
||||||
|
isError: isSpanError,
|
||||||
|
isFetching: isSpanFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
REACT_QUERY_KEY.SPAN_LOGS,
|
||||||
|
traceId,
|
||||||
|
spanId,
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
],
|
||||||
|
queryFn: () => GetMetricQueryRange(spanQueryPayload, ENTITY_VERSION_V5),
|
||||||
|
enabled: !!traceId && !!spanId,
|
||||||
|
staleTime: FIVE_MINUTES_IN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract span logs and track their IDs
|
||||||
|
const spanLogs = useMemo(() => {
|
||||||
|
if (!spanData?.payload?.data?.newResult?.data?.result?.[0]?.list) {
|
||||||
|
setSpanLogIds(new Set());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = spanData.payload.data.newResult.data.result[0].list.map(
|
||||||
|
(item: any) => ({
|
||||||
|
...item.data,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track span log IDs
|
||||||
|
const logIds = new Set(logs.map((log: ILog) => log.id));
|
||||||
|
setSpanLogIds(logIds);
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
}, [spanData]);
|
||||||
|
|
||||||
|
// Get first and last span logs for context queries
|
||||||
|
const { firstSpanLog, lastSpanLog } = useMemo(() => {
|
||||||
|
if (spanLogs.length === 0) return { firstSpanLog: null, lastSpanLog: null };
|
||||||
|
|
||||||
|
const sortedLogs = [...spanLogs].sort(
|
||||||
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstSpanLog: sortedLogs[0],
|
||||||
|
lastSpanLog: sortedLogs[sortedLogs.length - 1],
|
||||||
|
};
|
||||||
|
}, [spanLogs]);
|
||||||
|
// Phase 2: Fetch context logs before first span log
|
||||||
|
const beforeFilter = useMemo(() => {
|
||||||
|
if (!firstSpanLog) return null;
|
||||||
|
return createContextFilters(traceId, firstSpanLog.id, 'lt');
|
||||||
|
}, [traceId, firstSpanLog]);
|
||||||
|
|
||||||
|
const beforeQueryPayload = useMemo(() => {
|
||||||
|
if (!beforeFilter) return null;
|
||||||
|
return getSpanLogsQueryPayload(
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
beforeFilter,
|
||||||
|
);
|
||||||
|
}, [timeRange.startTime, timeRange.endTime, beforeFilter]);
|
||||||
|
|
||||||
|
const { data: beforeData, isFetching: isBeforeFetching } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
REACT_QUERY_KEY.SPAN_BEFORE_LOGS,
|
||||||
|
traceId,
|
||||||
|
firstSpanLog?.id,
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
GetMetricQueryRange(beforeQueryPayload as any, ENTITY_VERSION_V5),
|
||||||
|
enabled: !!beforeQueryPayload && !!firstSpanLog,
|
||||||
|
staleTime: FIVE_MINUTES_IN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 3: Fetch context logs after last span log
|
||||||
|
const afterFilter = useMemo(() => {
|
||||||
|
if (!lastSpanLog) return null;
|
||||||
|
return createContextFilters(traceId, lastSpanLog.id, 'gt');
|
||||||
|
}, [traceId, lastSpanLog]);
|
||||||
|
|
||||||
|
const afterQueryPayload = useMemo(() => {
|
||||||
|
if (!afterFilter) return null;
|
||||||
|
return getSpanLogsQueryPayload(
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
afterFilter,
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
}, [timeRange.startTime, timeRange.endTime, afterFilter]);
|
||||||
|
|
||||||
|
const { data: afterData, isFetching: isAfterFetching } = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
REACT_QUERY_KEY.SPAN_AFTER_LOGS,
|
||||||
|
traceId,
|
||||||
|
lastSpanLog?.id,
|
||||||
|
timeRange.startTime,
|
||||||
|
timeRange.endTime,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
GetMetricQueryRange(afterQueryPayload as any, ENTITY_VERSION_V5),
|
||||||
|
enabled: !!afterQueryPayload && !!lastSpanLog,
|
||||||
|
staleTime: FIVE_MINUTES_IN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract context logs
|
||||||
|
const beforeLogs = useMemo(() => {
|
||||||
|
if (!beforeData?.payload?.data?.newResult?.data?.result?.[0]?.list) return [];
|
||||||
|
|
||||||
|
return beforeData.payload.data.newResult.data.result[0].list.map(
|
||||||
|
(item: any) => ({
|
||||||
|
...item.data,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [beforeData]);
|
||||||
|
|
||||||
|
const afterLogs = useMemo(() => {
|
||||||
|
if (!afterData?.payload?.data?.newResult?.data?.result?.[0]?.list) return [];
|
||||||
|
|
||||||
|
return afterData.payload.data.newResult.data.result[0].list.map(
|
||||||
|
(item: any) => ({
|
||||||
|
...item.data,
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [afterData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const combined = [...afterLogs.reverse(), ...spanLogs, ...beforeLogs];
|
||||||
|
setAllLogs(combined);
|
||||||
|
}, [beforeLogs, spanLogs, afterLogs]);
|
||||||
|
|
||||||
|
// Helper function to check if a log belongs to the span
|
||||||
|
const isLogSpanRelated = useCallback(
|
||||||
|
(logId: string): boolean => spanLogIds.has(logId),
|
||||||
|
[spanLogIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs: allLogs,
|
||||||
|
isLoading: isSpanLoading && spanLogs.length === 0,
|
||||||
|
isError: isSpanError,
|
||||||
|
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||||
|
spanLogIds,
|
||||||
|
isLogSpanRelated,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
.span-related-signals-drawer {
|
||||||
|
.ant-drawer-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-drawer-header {
|
||||||
|
border-bottom: 1px solid var(--bg-slate-500);
|
||||||
|
padding: 16px 15px;
|
||||||
|
.title {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-divider {
|
||||||
|
margin-inline-start: 10px !important;
|
||||||
|
margin-inline-end: 16px !important;
|
||||||
|
height: 16px;
|
||||||
|
border-color: var(--bg-slate-500);
|
||||||
|
}
|
||||||
|
.ant-drawer-close {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__content {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.views-tabs-container {
|
||||||
|
padding: 16px 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
.open-in-explorer {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-radio-button-wrapper {
|
||||||
|
width: 114px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
.view-title {
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.06px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__applied-filters {
|
||||||
|
padding: 11px;
|
||||||
|
margin-inline: 16px;
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__filters-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__filter-tag {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-slate-300);
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-placeholder {
|
||||||
|
height: 50vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.infra-placeholder-content {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--bg-slate-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.span-related-signals-drawer {
|
||||||
|
.ant-drawer-header {
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.views-tabs-container {
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
|
.views-tabs {
|
||||||
|
.ant-radio-button-wrapper {
|
||||||
|
border-color: var(--bg-vanilla-300);
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected_view {
|
||||||
|
background: var(--bg-robin-500);
|
||||||
|
border-color: var(--bg-robin-500);
|
||||||
|
color: var(--bg-vanilla-100);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-robin-400);
|
||||||
|
border-color: var(--bg-robin-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__applied-filters {
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-related-signals-drawer__filter-tag {
|
||||||
|
background-color: var(--bg-vanilla-400);
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.infra-placeholder-content {
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.open-in-explorer {
|
||||||
|
background: var(--bg-vanilla-300);
|
||||||
|
}
|
||||||
|
.views-tabs-container {
|
||||||
|
.ant-radio-button-wrapper {
|
||||||
|
.view-title {
|
||||||
|
color: var(--bg-ink-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,237 @@
|
|||||||
|
import './SpanRelatedSignals.styles.scss';
|
||||||
|
|
||||||
|
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||||
|
import { Button, Divider, Drawer, Typography } from 'antd';
|
||||||
|
import { RadioChangeEvent } from 'antd/lib';
|
||||||
|
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||||
|
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import {
|
||||||
|
initialQueryBuilderFormValuesMap,
|
||||||
|
initialQueryState,
|
||||||
|
} from 'constants/queryBuilder';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
|
import { Compass, X } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||||
|
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||||
|
|
||||||
|
import { RelatedSignalsViews } from '../constants';
|
||||||
|
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||||
|
|
||||||
|
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface AppliedFiltersProps {
|
||||||
|
filters: TagFilterItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="span-related-signals-drawer__applied-filters">
|
||||||
|
<div className="span-related-signals-drawer__filters-list">
|
||||||
|
{filters.map((filter) => (
|
||||||
|
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
|
||||||
|
<Typography.Text>
|
||||||
|
{filter.key?.key}={filter.value}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpanRelatedSignalsProps {
|
||||||
|
selectedSpan: Span;
|
||||||
|
traceStartTime: number;
|
||||||
|
traceEndTime: number;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialView: RelatedSignalsViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpanRelatedSignals({
|
||||||
|
selectedSpan,
|
||||||
|
traceStartTime,
|
||||||
|
traceEndTime,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialView,
|
||||||
|
}: SpanRelatedSignalsProps): JSX.Element {
|
||||||
|
const [selectedView, setSelectedView] = useState<RelatedSignalsViews>(
|
||||||
|
initialView,
|
||||||
|
);
|
||||||
|
const isDarkMode = useIsDarkMode();
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
|
||||||
|
setSelectedView(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClose = useCallback((): void => {
|
||||||
|
setSelectedView(RelatedSignalsViews.LOGS);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const appliedFilters = useMemo(
|
||||||
|
(): TagFilterItem[] => [
|
||||||
|
{
|
||||||
|
id: 'trace-id-filter',
|
||||||
|
key: {
|
||||||
|
key: 'trace_id',
|
||||||
|
id: 'trace-id-key',
|
||||||
|
dataType: 'string' as const,
|
||||||
|
isColumn: true,
|
||||||
|
type: '',
|
||||||
|
isJSON: false,
|
||||||
|
} as BaseAutocompleteData,
|
||||||
|
op: '=',
|
||||||
|
value: selectedSpan.traceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[selectedSpan.traceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExplorerPageRedirect = useCallback((): void => {
|
||||||
|
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||||
|
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
|
||||||
|
|
||||||
|
const traceIdFilter = {
|
||||||
|
op: 'AND',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'trace-id-filter',
|
||||||
|
key: {
|
||||||
|
key: 'trace_id',
|
||||||
|
id: 'trace-id-key',
|
||||||
|
dataType: 'string' as const,
|
||||||
|
isColumn: true,
|
||||||
|
type: '',
|
||||||
|
isJSON: false,
|
||||||
|
} as BaseAutocompleteData,
|
||||||
|
op: '=',
|
||||||
|
value: selectedSpan.traceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const compositeQuery = {
|
||||||
|
...initialQueryState,
|
||||||
|
queryType: 'builder',
|
||||||
|
builder: {
|
||||||
|
...initialQueryState.builder,
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
...initialQueryBuilderFormValuesMap.logs,
|
||||||
|
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||||
|
filters: traceIdFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
|
||||||
|
searchParams.set(QueryParams.startTime, startTimeMs.toString());
|
||||||
|
searchParams.set(QueryParams.endTime, endTimeMs.toString());
|
||||||
|
|
||||||
|
window.open(
|
||||||
|
`${window.location.origin}${
|
||||||
|
ROUTES.LOGS_EXPLORER
|
||||||
|
}?${searchParams.toString()}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
width="50%"
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<Divider type="vertical" />
|
||||||
|
<Typography.Text className="title">
|
||||||
|
Related Signals - {selectedSpan.name}
|
||||||
|
</Typography.Text>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
onClose={handleClose}
|
||||||
|
open={isOpen}
|
||||||
|
style={{
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
|
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||||
|
}}
|
||||||
|
className="span-related-signals-drawer"
|
||||||
|
destroyOnClose
|
||||||
|
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||||
|
>
|
||||||
|
{selectedSpan && (
|
||||||
|
<div className="span-related-signals-drawer__content">
|
||||||
|
<div className="views-tabs-container">
|
||||||
|
<SignozRadioGroup
|
||||||
|
value={selectedView}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div className="view-title">
|
||||||
|
<LogsIcon width={14} height={14} />
|
||||||
|
Logs
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: RelatedSignalsViews.LOGS,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: (
|
||||||
|
// <div className="view-title">
|
||||||
|
// <LogsIcon width={14} height={14} />
|
||||||
|
// Metrics
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// value: RelatedSignalsViews.METRICS,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// label: (
|
||||||
|
// <div className="view-title">
|
||||||
|
// <Server size={14} />
|
||||||
|
// Infra
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
// value: RelatedSignalsViews.INFRA,
|
||||||
|
// },
|
||||||
|
]}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
className="related-signals-radio"
|
||||||
|
/>
|
||||||
|
{selectedView === RelatedSignalsViews.LOGS && (
|
||||||
|
<Button
|
||||||
|
icon={<Compass size={18} />}
|
||||||
|
className="open-in-explorer"
|
||||||
|
onClick={handleExplorerPageRedirect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedView === RelatedSignalsViews.LOGS && (
|
||||||
|
<>
|
||||||
|
<AppliedFilters filters={appliedFilters} />
|
||||||
|
<SpanLogs
|
||||||
|
traceId={selectedSpan.traceId}
|
||||||
|
spanId={selectedSpan.spanId}
|
||||||
|
timeRange={{
|
||||||
|
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||||
|
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||||
|
}}
|
||||||
|
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpanRelatedSignals;
|
||||||
@ -20,6 +20,20 @@ const mockQueryClient = {
|
|||||||
fetchQuery: jest.fn(),
|
fetchQuery: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
jest.mock('uplot', () => {
|
||||||
|
const paths = {
|
||||||
|
spline: jest.fn(),
|
||||||
|
bars: jest.fn(),
|
||||||
|
};
|
||||||
|
const uplotMock = jest.fn(() => ({
|
||||||
|
paths,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
default: uplotMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the hooks
|
// Mock the hooks
|
||||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
useQueryBuilder: (): any => ({
|
useQueryBuilder: (): any => ({
|
||||||
@ -54,6 +68,8 @@ jest.mock('react-query', () => ({
|
|||||||
useQueryClient: (): any => mockQueryClient,
|
useQueryClient: (): any => mockQueryClient,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
|
||||||
|
|
||||||
// Mock the API response for getAggregateKeys
|
// Mock the API response for getAggregateKeys
|
||||||
const mockAggregateKeysResponse = {
|
const mockAggregateKeysResponse = {
|
||||||
payload: {
|
payload: {
|
||||||
@ -123,12 +139,11 @@ const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
|
|||||||
isSpanDetailsDocked={false}
|
isSpanDetailsDocked={false}
|
||||||
setIsSpanDetailsDocked={jest.fn()}
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
selectedSpan={span}
|
selectedSpan={span}
|
||||||
traceID={span.traceId}
|
|
||||||
traceStartTime={span.timestamp}
|
traceStartTime={span.timestamp}
|
||||||
traceEndTime={span.timestamp + span.durationNano}
|
traceEndTime={span.timestamp + span.durationNano}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</MemoryRouter>{' '}
|
</MemoryRouter>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -0,0 +1,509 @@
|
|||||||
|
import { QueryParams } from 'constants/query';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
|
import { server } from 'mocks-server/server';
|
||||||
|
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||||
|
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||||
|
import {
|
||||||
|
expectedAfterFilterExpression,
|
||||||
|
expectedBeforeFilterExpression,
|
||||||
|
expectedSpanFilterExpression,
|
||||||
|
mockAfterLogsResponse,
|
||||||
|
mockBeforeLogsResponse,
|
||||||
|
mockEmptyLogsResponse,
|
||||||
|
mockSpan,
|
||||||
|
mockSpanLogsResponse,
|
||||||
|
} from './mockData';
|
||||||
|
|
||||||
|
// Mock external dependencies
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useLocation: (): { pathname: string } => ({
|
||||||
|
pathname: `${ROUTES.TRACE_DETAIL}`,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSafeNavigate = jest.fn();
|
||||||
|
jest.mock('hooks/useSafeNavigate', () => ({
|
||||||
|
useSafeNavigate: (): any => ({
|
||||||
|
safeNavigate: mockSafeNavigate,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
aggregateOperator: 'noop',
|
||||||
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
expression: 'A',
|
||||||
|
disabled: false,
|
||||||
|
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||||
|
groupBy: [],
|
||||||
|
limit: null,
|
||||||
|
having: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFormulas: [],
|
||||||
|
},
|
||||||
|
queryType: 'builder',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||||
|
useQueryBuilder: (): any => ({
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWindowOpen = jest.fn();
|
||||||
|
Object.defineProperty(window, 'open', {
|
||||||
|
writable: true,
|
||||||
|
value: mockWindowOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock uplot to avoid rendering issues
|
||||||
|
jest.mock('uplot', () => {
|
||||||
|
const paths = {
|
||||||
|
spline: jest.fn(),
|
||||||
|
bars: jest.fn(),
|
||||||
|
};
|
||||||
|
const uplotMock = jest.fn(() => ({
|
||||||
|
paths,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
paths,
|
||||||
|
default: uplotMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||||
|
GetMetricQueryRange: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||||
|
generateColor: jest.fn().mockReturnValue('#1f77b4'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'components/OverlayScrollbar/OverlayScrollbar',
|
||||||
|
() =>
|
||||||
|
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||||
|
function ({ children }: any) {
|
||||||
|
return <div data-testid="overlay-scrollbar">{children}</div>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock Virtuoso to avoid complex virtualization
|
||||||
|
jest.mock('react-virtuoso', () => ({
|
||||||
|
Virtuoso: jest.fn(({ data, itemContent }) => (
|
||||||
|
<div data-testid="virtuoso">
|
||||||
|
{data?.map((item: any, index: number) => (
|
||||||
|
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||||
|
{itemContent(index, item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock RawLogView component
|
||||||
|
jest.mock(
|
||||||
|
'components/Logs/RawLogView',
|
||||||
|
() =>
|
||||||
|
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||||
|
function MockRawLogView({
|
||||||
|
data,
|
||||||
|
onLogClick,
|
||||||
|
isHighlighted,
|
||||||
|
helpTooltip,
|
||||||
|
}: any) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||||
|
<div
|
||||||
|
data-testid={`raw-log-${data.id}`}
|
||||||
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
|
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||||
|
title={helpTooltip}
|
||||||
|
onClick={(e): void => onLogClick?.(data, e)}
|
||||||
|
>
|
||||||
|
<div>{data.body}</div>
|
||||||
|
<div>{data.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock PreferenceContextProvider
|
||||||
|
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||||
|
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SpanDetailsDrawer', () => {
|
||||||
|
let apiCallHistory: any[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
apiCallHistory = [];
|
||||||
|
mockSafeNavigate.mockClear();
|
||||||
|
mockWindowOpen.mockClear();
|
||||||
|
mockUpdateAllQueriesOperators.mockClear();
|
||||||
|
|
||||||
|
// Setup API call tracking
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||||
|
apiCallHistory.push(query);
|
||||||
|
|
||||||
|
// Determine response based on v5 filter expressions
|
||||||
|
const filterExpression =
|
||||||
|
query.query?.builder?.queryData?.[0]?.filter?.expression;
|
||||||
|
|
||||||
|
if (!filterExpression) return Promise.resolve(mockEmptyLogsResponse);
|
||||||
|
|
||||||
|
// Check for span logs query (contains both trace_id and span_id)
|
||||||
|
if (filterExpression.includes('span_id')) {
|
||||||
|
return Promise.resolve(mockSpanLogsResponse);
|
||||||
|
}
|
||||||
|
// Check for before logs query (contains trace_id and id <)
|
||||||
|
if (filterExpression.includes('id <')) {
|
||||||
|
return Promise.resolve(mockBeforeLogsResponse);
|
||||||
|
}
|
||||||
|
// Check for after logs query (contains trace_id and id >)
|
||||||
|
if (filterExpression.includes('id >')) {
|
||||||
|
return Promise.resolve(mockAfterLogsResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(mockEmptyLogsResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock QueryBuilder context value
|
||||||
|
const mockQueryBuilderContextValue = {
|
||||||
|
currentQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stagedQuery: {
|
||||||
|
builder: {
|
||||||
|
queryData: [
|
||||||
|
{
|
||||||
|
dataSource: 'logs',
|
||||||
|
queryName: 'A',
|
||||||
|
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||||
|
panelType: 'list',
|
||||||
|
redirectWithQuery: jest.fn(),
|
||||||
|
handleRunQuery: jest.fn(),
|
||||||
|
handleStageQuery: jest.fn(),
|
||||||
|
resetQuery: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSpanDetailsDrawer = (props = {}): void => {
|
||||||
|
render(
|
||||||
|
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||||
|
<SpanDetailsDrawer
|
||||||
|
isSpanDetailsDocked={false}
|
||||||
|
setIsSpanDetailsDocked={jest.fn()}
|
||||||
|
selectedSpan={mockSpan}
|
||||||
|
traceStartTime={1640995200000} // 2022-01-01 00:00:00 in milliseconds
|
||||||
|
traceEndTime={1640995260000} // 2022-01-01 00:01:00 in milliseconds
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</QueryBuilderContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should display logs tab in right sidebar when span is selected', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Verify logs tab is visible
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
expect(logsButton).toBeInTheDocument();
|
||||||
|
expect(logsButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open related logs view when logs tab is clicked', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Click on logs tab
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for logs view to open
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify logs are displayed
|
||||||
|
await waitFor(() => {
|
||||||
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
|
||||||
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
|
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make three API queries when logs tab is opened', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Click on logs tab to trigger API calls
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for all API calls to complete
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the three distinct queries were made
|
||||||
|
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||||
|
|
||||||
|
// 1. Span logs query (trace_id + span_id)
|
||||||
|
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||||
|
expectedSpanFilterExpression,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Before logs query (trace_id + id < first_span_log_id)
|
||||||
|
expect(beforeQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||||
|
expectedBeforeFilterExpression,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. After logs query (trace_id + id > last_span_log_id)
|
||||||
|
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||||
|
expectedAfterFilterExpression,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct timestamp ordering for different query types', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Click on logs tab to trigger API calls
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for all API calls to complete
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||||
|
|
||||||
|
// Verify ordering: span query should use 'desc' (default)
|
||||||
|
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||||
|
|
||||||
|
// Before query should use 'desc' (default)
|
||||||
|
expect(beforeQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||||
|
|
||||||
|
// After query should use 'asc' for chronological order
|
||||||
|
expect(afterQuery.query.builder.queryData[0].orderBy[0].order).toBe('asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to logs explorer with span filters when span log is clicked', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Open logs view
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for logs to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on a span log (highlighted)
|
||||||
|
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
fireEvent.click(spanLog);
|
||||||
|
|
||||||
|
// Verify window.open was called with correct parameters
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check navigation URL contains expected parameters
|
||||||
|
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||||
|
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||||
|
|
||||||
|
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
|
||||||
|
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000'); // traceStartTime - 5 minutes
|
||||||
|
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
|
||||||
|
|
||||||
|
// Verify composite query includes both trace_id and span_id filters
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||||
|
);
|
||||||
|
const { filter } = compositeQuery.builder.queryData[0];
|
||||||
|
|
||||||
|
// Check that the filter expression contains trace_id
|
||||||
|
// Note: Current behavior uses only trace_id filter for navigation
|
||||||
|
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
|
||||||
|
|
||||||
|
// Verify mockSafeNavigate was NOT called
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate to logs explorer with trace filter when context log is clicked', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Open logs view
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for logs to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-context-log-before')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on a context log (non-highlighted)
|
||||||
|
const contextLog = screen.getByTestId('raw-log-context-log-before');
|
||||||
|
fireEvent.click(contextLog);
|
||||||
|
|
||||||
|
// Verify window.open was called
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check navigation URL parameters
|
||||||
|
const navigationCall = mockWindowOpen.mock.calls[0][0];
|
||||||
|
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||||
|
|
||||||
|
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
|
||||||
|
|
||||||
|
// Verify composite query includes only trace_id filter (no span_id for context logs)
|
||||||
|
const compositeQuery = JSON.parse(
|
||||||
|
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||||
|
);
|
||||||
|
const { filter } = compositeQuery.builder.queryData[0];
|
||||||
|
|
||||||
|
// Check that the filter expression contains trace_id but not span_id for context logs
|
||||||
|
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
|
||||||
|
// Context logs should not have span_id filter
|
||||||
|
expect(filter.expression).not.toContain('span_id');
|
||||||
|
|
||||||
|
// Verify mockSafeNavigate was NOT called
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always open logs explorer in new tab regardless of click type', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Open logs view
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for logs to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regular click on a log
|
||||||
|
const spanLog = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
fireEvent.click(spanLog);
|
||||||
|
|
||||||
|
// Verify window.open was called for new tab
|
||||||
|
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(ROUTES.LOGS_EXPLORER),
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify navigate was NOT called (always opens new tab)
|
||||||
|
expect(mockSafeNavigate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty logs state', async () => {
|
||||||
|
// Mock empty response for all queries
|
||||||
|
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
|
||||||
|
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Open logs view
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait and verify empty state is shown
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(/No logs found for selected span/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display span logs as highlighted and context logs as regular', async () => {
|
||||||
|
renderSpanDetailsDrawer();
|
||||||
|
|
||||||
|
// Open logs view
|
||||||
|
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||||
|
fireEvent.click(logsButton);
|
||||||
|
|
||||||
|
// Wait for logs to load
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify span logs are highlighted
|
||||||
|
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
|
||||||
|
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
|
||||||
|
expect(spanLog1).toHaveClass('log-highlighted');
|
||||||
|
expect(spanLog2).toHaveClass('log-highlighted');
|
||||||
|
expect(spanLog1).toHaveAttribute(
|
||||||
|
'title',
|
||||||
|
'This log belongs to the current span',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify context logs are not highlighted
|
||||||
|
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
|
||||||
|
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
|
||||||
|
expect(contextLogBefore).toHaveClass('log-context');
|
||||||
|
expect(contextLogAfter).toHaveClass('log-context');
|
||||||
|
expect(contextLogBefore).not.toHaveAttribute('title');
|
||||||
|
});
|
||||||
|
});
|
||||||
209
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
209
frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { ILog } from 'types/api/logs/log';
|
||||||
|
import { Span } from 'types/api/trace/getTraceV2';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const TEST_SPAN_ID = 'test-span-id';
|
||||||
|
const TEST_TRACE_ID = 'test-trace-id';
|
||||||
|
const TEST_SERVICE = 'test-service';
|
||||||
|
|
||||||
|
// Mock span data
|
||||||
|
export const mockSpan: Span = {
|
||||||
|
spanId: TEST_SPAN_ID,
|
||||||
|
traceId: TEST_TRACE_ID,
|
||||||
|
name: TEST_SERVICE,
|
||||||
|
serviceName: TEST_SERVICE,
|
||||||
|
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||||
|
durationNano: 1000000000, // 1 second in nanoseconds
|
||||||
|
spanKind: 'server',
|
||||||
|
statusCodeString: 'STATUS_CODE_OK',
|
||||||
|
statusMessage: '',
|
||||||
|
parentSpanId: '',
|
||||||
|
references: [],
|
||||||
|
event: [],
|
||||||
|
tagMap: {
|
||||||
|
'http.method': 'GET',
|
||||||
|
'http.url': '/api/test',
|
||||||
|
'http.status_code': '200',
|
||||||
|
},
|
||||||
|
hasError: false,
|
||||||
|
rootSpanId: '',
|
||||||
|
kind: 0,
|
||||||
|
rootName: '',
|
||||||
|
hasChildren: false,
|
||||||
|
hasSibling: false,
|
||||||
|
subTreeNodeCount: 0,
|
||||||
|
level: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock logs with proper relationships
|
||||||
|
export const mockSpanLogs: ILog[] = [
|
||||||
|
{
|
||||||
|
id: 'span-log-1',
|
||||||
|
timestamp: '2022-01-01T00:00:01.000Z',
|
||||||
|
body: 'Processing request in span',
|
||||||
|
severity_text: 'INFO',
|
||||||
|
severity_number: 9,
|
||||||
|
spanID: TEST_SPAN_ID,
|
||||||
|
span_id: TEST_SPAN_ID,
|
||||||
|
date: '',
|
||||||
|
traceId: TEST_TRACE_ID,
|
||||||
|
traceFlags: 0,
|
||||||
|
severityText: '',
|
||||||
|
severityNumber: 0,
|
||||||
|
resources_string: {},
|
||||||
|
scope_string: {},
|
||||||
|
attributesString: {},
|
||||||
|
attributes_string: {},
|
||||||
|
attributesInt: {},
|
||||||
|
attributesFloat: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'span-log-2',
|
||||||
|
timestamp: '2022-01-01T00:00:02.000Z',
|
||||||
|
body: 'Span operation completed',
|
||||||
|
severity_text: 'INFO',
|
||||||
|
severity_number: 9,
|
||||||
|
spanID: TEST_SPAN_ID,
|
||||||
|
span_id: TEST_SPAN_ID,
|
||||||
|
date: '',
|
||||||
|
traceId: TEST_TRACE_ID,
|
||||||
|
traceFlags: 0,
|
||||||
|
severityText: '',
|
||||||
|
severityNumber: 0,
|
||||||
|
resources_string: {},
|
||||||
|
scope_string: {},
|
||||||
|
attributesString: {},
|
||||||
|
attributes_string: {},
|
||||||
|
attributesInt: {},
|
||||||
|
attributesFloat: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockContextLogs: ILog[] = [
|
||||||
|
{
|
||||||
|
id: 'context-log-before',
|
||||||
|
timestamp: '2021-12-31T23:59:59.000Z',
|
||||||
|
body: 'Context log before span',
|
||||||
|
severity_text: 'INFO',
|
||||||
|
severity_number: 9,
|
||||||
|
spanID: 'different-span-id',
|
||||||
|
span_id: 'different-span-id',
|
||||||
|
date: '',
|
||||||
|
traceId: TEST_TRACE_ID,
|
||||||
|
traceFlags: 0,
|
||||||
|
severityText: '',
|
||||||
|
severityNumber: 0,
|
||||||
|
resources_string: {},
|
||||||
|
scope_string: {},
|
||||||
|
attributesString: {},
|
||||||
|
attributes_string: {},
|
||||||
|
attributesInt: {},
|
||||||
|
attributesFloat: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context-log-after',
|
||||||
|
timestamp: '2022-01-01T00:00:03.000Z',
|
||||||
|
body: 'Context log after span',
|
||||||
|
severity_text: 'INFO',
|
||||||
|
severity_number: 9,
|
||||||
|
spanID: 'another-different-span-id',
|
||||||
|
span_id: 'another-different-span-id',
|
||||||
|
date: '',
|
||||||
|
traceId: TEST_TRACE_ID,
|
||||||
|
traceFlags: 0,
|
||||||
|
severityText: '',
|
||||||
|
severityNumber: 0,
|
||||||
|
resources_string: {},
|
||||||
|
scope_string: {},
|
||||||
|
attributesString: {},
|
||||||
|
attributes_string: {},
|
||||||
|
attributesInt: {},
|
||||||
|
attributesFloat: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Combined logs in chronological order
|
||||||
|
export const mockAllLogs: ILog[] = [
|
||||||
|
mockContextLogs[0], // before
|
||||||
|
...mockSpanLogs, // span logs
|
||||||
|
mockContextLogs[1], // after
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock API responses
|
||||||
|
export const mockSpanLogsResponse = {
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
newResult: {
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
list: mockSpanLogs.map((log) => ({
|
||||||
|
data: log,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockBeforeLogsResponse = {
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
newResult: {
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
list: [mockContextLogs[0]].map((log) => ({
|
||||||
|
data: log,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockAfterLogsResponse = {
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
newResult: {
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
list: [mockContextLogs[1]].map((log) => ({
|
||||||
|
data: log,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockEmptyLogsResponse = {
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
newResult: {
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
list: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expected v5 filter expressions
|
||||||
|
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
|
||||||
|
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
|
||||||
|
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
|
||||||
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
11
frontend/src/container/SpanDetailsDrawer/constants.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export enum RelatedSignalsViews {
|
||||||
|
LOGS = 'logs',
|
||||||
|
// METRICS = 'metrics',
|
||||||
|
// INFRA = 'infra',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RELATED_SIGNALS_VIEW_TYPES = {
|
||||||
|
LOGS: RelatedSignalsViews.LOGS,
|
||||||
|
// METRICS: RelatedSignalsViews.METRICS,
|
||||||
|
// INFRA: RelatedSignalsViews.INFRA,
|
||||||
|
};
|
||||||
@ -149,7 +149,6 @@ function TraceDetailsV2(): JSX.Element {
|
|||||||
isSpanDetailsDocked={isSpanDetailsDocked}
|
isSpanDetailsDocked={isSpanDetailsDocked}
|
||||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||||
selectedSpan={selectedSpan}
|
selectedSpan={selectedSpan}
|
||||||
traceID={traceId}
|
|
||||||
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
||||||
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface ILog {
|
|||||||
id: string;
|
id: string;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
spanID: string;
|
spanID: string;
|
||||||
|
span_id?: string;
|
||||||
traceFlags: number;
|
traceFlags: number;
|
||||||
severityText: string;
|
severityText: string;
|
||||||
severityNumber: number;
|
severityNumber: number;
|
||||||
|
|||||||
@ -5,7 +5,10 @@ export interface PayloadProps {
|
|||||||
result: QueryData[];
|
result: QueryData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListItem = { timestamp: string; data: Omit<ILog, 'timestamp'> };
|
export type ListItem = {
|
||||||
|
timestamp: string;
|
||||||
|
data: Omit<ILog, 'timestamp' | 'span_id'>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface QueryData {
|
export interface QueryData {
|
||||||
lowerBoundSeries?: [number, string][];
|
lowerBoundSeries?: [number, string][];
|
||||||
|
|||||||
@ -48,3 +48,13 @@ export const getHightLightedLogBackground = (
|
|||||||
if (!isHighlightedLog) return '';
|
if (!isHighlightedLog) return '';
|
||||||
return `background-color: ${orange[3]};`;
|
return `background-color: ${orange[3]};`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCustomHighlightBackground = (
|
||||||
|
isHighlighted = false,
|
||||||
|
isDarkMode = true,
|
||||||
|
$logType: string,
|
||||||
|
): string => {
|
||||||
|
if (!isHighlighted) return '';
|
||||||
|
|
||||||
|
return getActiveLogBackground(true, isDarkMode, $logType);
|
||||||
|
};
|
||||||
|
|||||||
@ -4247,7 +4247,7 @@
|
|||||||
tailwind-merge "^2.5.2"
|
tailwind-merge "^2.5.2"
|
||||||
tailwindcss-animate "^1.0.7"
|
tailwindcss-animate "^1.0.7"
|
||||||
|
|
||||||
"@signozhq/button@^0.0.2":
|
"@signozhq/button@0.0.2", "@signozhq/button@^0.0.2":
|
||||||
version "0.0.2"
|
version "0.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f"
|
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f"
|
||||||
integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==
|
integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user