mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +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/webpack-plugin": "2.22.6",
|
||||
"@signozhq/badge": "0.0.2",
|
||||
"@signozhq/button": "0.0.2",
|
||||
"@signozhq/calendar": "0.0.0",
|
||||
"@signozhq/callout": "0.0.2",
|
||||
"@signozhq/design-tokens": "1.1.4",
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import './RawLogView.styles.scss';
|
||||
|
||||
import { DrawerProps } from 'antd';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DrawerProps, Tooltip } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@ -26,7 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
|
||||
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from '../LogStateIndicator/utils';
|
||||
// styles
|
||||
import { RawLogContent, RawLogViewContainer } from './styles';
|
||||
import { InfoIconWrapper, RawLogContent, RawLogViewContainer } from './styles';
|
||||
import { RawLogViewProps } from './types';
|
||||
|
||||
function RawLogView({
|
||||
@ -35,12 +34,17 @@ function RawLogView({
|
||||
data,
|
||||
linesPerRow,
|
||||
isTextOverflowEllipsisDisabled,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
selectedFields = [],
|
||||
fontSize,
|
||||
onLogClick,
|
||||
}: RawLogViewProps): JSX.Element {
|
||||
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
|
||||
data.id,
|
||||
);
|
||||
const {
|
||||
isHighlighted: isUrlHighlighted,
|
||||
isLogsExplorerPage,
|
||||
onLogCopy,
|
||||
} = useCopyLogLink(data.id);
|
||||
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
|
||||
|
||||
const {
|
||||
@ -126,12 +130,20 @@ function RawLogView({
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
]);
|
||||
|
||||
const handleClickExpand = useCallback(() => {
|
||||
const handleClickExpand = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (activeContextLog || isReadOnly) return;
|
||||
|
||||
// Use custom click handler if provided, otherwise use default behavior
|
||||
if (onLogClick) {
|
||||
onLogClick(data, event);
|
||||
} else {
|
||||
onSetActiveLog(data);
|
||||
setSelectedTab(VIEW_TYPES.OVERVIEW);
|
||||
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
|
||||
}
|
||||
},
|
||||
[activeContextLog, isReadOnly, data, onSetActiveLog, onLogClick],
|
||||
);
|
||||
|
||||
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
|
||||
(
|
||||
@ -183,10 +195,11 @@ function RawLogView({
|
||||
align="middle"
|
||||
$isDarkMode={isDarkMode}
|
||||
$isReadOnly={isReadOnly}
|
||||
$isHightlightedLog={isHighlighted}
|
||||
$isHightlightedLog={isUrlHighlighted}
|
||||
$isActiveLog={
|
||||
activeLog?.id === data.id || activeContextLog?.id === data.id || isActiveLog
|
||||
}
|
||||
$isCustomHighlighted={isHighlighted}
|
||||
$logType={logType}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@ -197,6 +210,15 @@ function RawLogView({
|
||||
severityText={data.severity_text}
|
||||
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
|
||||
className="raw-log-content"
|
||||
@ -240,6 +262,7 @@ RawLogView.defaultProps = {
|
||||
isActiveLog: false,
|
||||
isReadOnly: false,
|
||||
isTextOverflowEllipsisDisabled: false,
|
||||
isHighlighted: false,
|
||||
};
|
||||
|
||||
export default RawLogView;
|
||||
|
||||
@ -3,8 +3,13 @@ import { blue } from '@ant-design/colors';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Col, Row, Space } from 'antd';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { Info } from 'lucide-react';
|
||||
import styled from 'styled-components';
|
||||
import { getActiveLogBackground, getDefaultLogBackground } from 'utils/logs';
|
||||
import {
|
||||
getActiveLogBackground,
|
||||
getCustomHighlightBackground,
|
||||
getDefaultLogBackground,
|
||||
} from 'utils/logs';
|
||||
|
||||
import { RawLogContentProps } from './types';
|
||||
|
||||
@ -13,6 +18,7 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
$isReadOnly?: boolean;
|
||||
$isActiveLog?: boolean;
|
||||
$isHightlightedLog: boolean;
|
||||
$isCustomHighlighted?: boolean;
|
||||
$logType: string;
|
||||
fontSize: FontSize;
|
||||
}>`
|
||||
@ -50,6 +56,18 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
};
|
||||
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)`
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { MouseEvent } from 'react';
|
||||
import { IField } from 'types/api/logs/fields';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
|
||||
@ -6,10 +7,13 @@ export interface RawLogViewProps {
|
||||
isActiveLog?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isTextOverflowEllipsisDisabled?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
helpTooltip?: string;
|
||||
data: ILog;
|
||||
linesPerRow: number;
|
||||
fontSize: FontSize;
|
||||
selectedFields?: IField[];
|
||||
onLogClick?: (log: ILog, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export interface RawLogContentProps {
|
||||
|
||||
@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@ -83,4 +83,7 @@ export const REACT_QUERY_KEY = {
|
||||
// Quick Filters Query Keys
|
||||
GET_CUSTOM_FILTERS: 'GET_CUSTOM_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;
|
||||
|
||||
@ -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 {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-extra-content {
|
||||
@ -268,10 +250,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.related-logs {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.attributes-events {
|
||||
.details-drawer-tabs {
|
||||
.ant-tabs-nav::before {
|
||||
|
||||
@ -1,30 +1,28 @@
|
||||
import './SpanDetailsDrawer.styles.scss';
|
||||
|
||||
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
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 { 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 { formatEpochTimestamp } from 'utils/timeUtils';
|
||||
|
||||
import Attributes from './Attributes/Attributes';
|
||||
import { RelatedSignalsViews } from './constants';
|
||||
import Events from './Events/Events';
|
||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
interface ISpanDetailsDrawerProps {
|
||||
isSpanDetailsDocked: boolean;
|
||||
setIsSpanDetailsDocked: Dispatch<SetStateAction<boolean>>;
|
||||
selectedSpan: Span | undefined;
|
||||
traceID: string;
|
||||
traceStartTime: number;
|
||||
traceEndTime: number;
|
||||
}
|
||||
@ -35,16 +33,31 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
setIsSpanDetailsDocked,
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceID,
|
||||
traceEndTime,
|
||||
} = props;
|
||||
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [activeDrawerView, setActiveDrawerView] = useState<RelatedSignalsViews>(
|
||||
RelatedSignalsViews.LOGS,
|
||||
);
|
||||
const color = generateColor(
|
||||
selectedSpan?.serviceName || '',
|
||||
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'] {
|
||||
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 (
|
||||
<div
|
||||
@ -216,12 +216,49 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</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>
|
||||
|
||||
<Button onClick={onLogsHandler} className="related-logs">
|
||||
Go to related logs
|
||||
</Button>
|
||||
|
||||
<section className="attributes-events">
|
||||
<Tabs
|
||||
items={getItems(selectedSpan, traceStartTime)}
|
||||
@ -240,6 +277,18 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSpan && (
|
||||
<SpanRelatedSignals
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
isOpen={isRelatedSignalsOpen}
|
||||
onClose={handleRelatedSignalsClose}
|
||||
initialView={activeDrawerView}
|
||||
key={activeDrawerView}
|
||||
/>
|
||||
)}
|
||||
</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(),
|
||||
};
|
||||
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
@ -54,6 +68,8 @@ jest.mock('react-query', () => ({
|
||||
useQueryClient: (): any => mockQueryClient,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/sonner', () => ({ toast: jest.fn() }));
|
||||
|
||||
// Mock the API response for getAggregateKeys
|
||||
const mockAggregateKeysResponse = {
|
||||
payload: {
|
||||
@ -123,12 +139,11 @@ const renderSpanDetailsDrawer = (span: Span = createMockSpan()): any => {
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={span}
|
||||
traceID={span.traceId}
|
||||
traceStartTime={span.timestamp}
|
||||
traceEndTime={span.timestamp + span.durationNano}
|
||||
/>
|
||||
</Route>
|
||||
</MemoryRouter>{' '}
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</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}
|
||||
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
|
||||
selectedSpan={selectedSpan}
|
||||
traceID={traceId}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
|
||||
/>
|
||||
|
||||
@ -4,6 +4,7 @@ export interface ILog {
|
||||
id: string;
|
||||
traceId: string;
|
||||
spanID: string;
|
||||
span_id?: string;
|
||||
traceFlags: number;
|
||||
severityText: string;
|
||||
severityNumber: number;
|
||||
|
||||
@ -5,7 +5,10 @@ export interface PayloadProps {
|
||||
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 {
|
||||
lowerBoundSeries?: [number, string][];
|
||||
|
||||
@ -48,3 +48,13 @@ export const getHightLightedLogBackground = (
|
||||
if (!isHighlightedLog) return '';
|
||||
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"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/button@^0.0.2":
|
||||
"@signozhq/button@0.0.2", "@signozhq/button@^0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/button/-/button-0.0.2.tgz#c13edef1e735134b784a41f874b60a14bc16993f"
|
||||
integrity sha512-434/gbTykC00LrnzFPp7c33QPWZkf9n+8+SToLZFTB0rzcaS/xoB4b7QKhvk+8xLCj4zpw6BxfeRAL+gSoOUJw==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user