feat: add support for span logs (#8857)

This commit is contained in:
Shaheer Kochai 2025-09-16 15:52:15 +04:30 committed by GitHub
parent 4851527840
commit 6c57735a81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 2077 additions and 68 deletions

View File

@ -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",

View File

@ -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(() => {
if (activeContextLog || isReadOnly) return;
const handleClickExpand = useCallback(
(event: MouseEvent) => {
if (activeContextLog || isReadOnly) return;
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
}, [activeContextLog, isReadOnly, data, onSetActiveLog]);
// 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, 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;

View File

@ -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)`

View File

@ -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 {

View File

@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
}

View File

@ -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;

View File

@ -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 {

View File

@ -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>
);
}

View 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;

View File

@ -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',
});

View File

@ -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);
}
}
}
}
}
}

View File

@ -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,
};
};

View File

@ -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);
}
}
}
}
}

View File

@ -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;

View File

@ -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>,
);

View File

@ -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');
});
});

View 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'`;

View 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,
};

View File

@ -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}
/>

View File

@ -4,6 +4,7 @@ export interface ILog {
id: string;
traceId: string;
spanID: string;
span_id?: string;
traceFlags: number;
severityText: string;
severityNumber: number;

View File

@ -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][];

View File

@ -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);
};

View File

@ -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==