diff --git a/frontend/package.json b/frontend/package.json index 61514485ffd1..5419afc5e767 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/Logs/RawLogView/RawLogView.styles.scss b/frontend/src/components/Logs/RawLogView/RawLogView.styles.scss deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/frontend/src/components/Logs/RawLogView/index.tsx b/frontend/src/components/Logs/RawLogView/index.tsx index c1f1cc346c69..c9b73497c594 100644 --- a/frontend/src/components/Logs/RawLogView/index.tsx +++ b/frontend/src/components/Logs/RawLogView/index.tsx @@ -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 && ( + + + + )} ` @@ -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)` diff --git a/frontend/src/components/Logs/RawLogView/types.ts b/frontend/src/components/Logs/RawLogView/types.ts index ed73725dcc34..5cbc3e8c2635 100644 --- a/frontend/src/components/Logs/RawLogView/types.ts +++ b/frontend/src/components/Logs/RawLogView/types.ts @@ -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 { diff --git a/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx b/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx index 9c9f0b88aa2b..e99faf04457c 100644 --- a/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx +++ b/frontend/src/components/SignozRadioGroup/SignozRadioGroup.tsx @@ -5,7 +5,7 @@ import { RadioChangeEvent } from 'antd/es/radio'; interface Option { value: string; - label: string; + label: string | React.ReactNode; icon?: React.ReactNode; } diff --git a/frontend/src/constants/reactQueryKeys.ts b/frontend/src/constants/reactQueryKeys.ts index 00721dfb4a69..6d34aa4b29c0 100644 --- a/frontend/src/constants/reactQueryKeys.ts +++ b/frontend/src/constants/reactQueryKeys.ts @@ -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; diff --git a/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.styles.scss b/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.styles.scss index 673084d2dd58..6c3e56d32d76 100644 --- a/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.styles.scss +++ b/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.styles.scss @@ -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 { diff --git a/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx b/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx index 2fd420a1119d..c66b1d6baf54 100644 --- a/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx +++ b/frontend/src/container/SpanDetailsDrawer/SpanDetailsDrawer.tsx @@ -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>; 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(false); + const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState( + false, + ); + const [activeDrawerView, setActiveDrawerView] = useState( + 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 (
)} +
+ + related signals + +
+ + + Logs +
+ ), + value: RelatedSignalsViews.LOGS, + }, + // { + // label: ( + //
+ // + // Metrics + //
+ // ), + // value: RelatedSignalsViews.METRICS, + // }, + // { + // label: ( + //
+ // + // Infra + //
+ // ), + // value: RelatedSignalsViews.INFRA, + // }, + ]} + onChange={handleRelatedSignalsChange} + className="related-signals-radio" + /> +
+ - -
)} + + {selectedSpan && ( + + )} ); } diff --git a/frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx b/frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx new file mode 100644 index 000000000000..e73bd39a5353 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanLogs/SpanLogs.tsx @@ -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 ( + + ); + }, + [handleLogClick, spanId], + ); + + const renderFooter = useCallback((): JSX.Element | null => { + if (isFetching) { + return
Loading more logs ...
; + } + + if (hasReachedEndOfLogs) { + return
*** End ***
; + } + + return null; + }, [isFetching, hasReachedEndOfLogs]); + + const renderContent = useMemo( + () => ( +
+ + + + + +
+ ), + [logs, getItemContent, renderFooter], + ); + + const renderNoLogsFound = (): JSX.Element => ( +
+
+ no-data + + No logs found for selected span. + + Try viewing logs for the current trace. + + +
+
+ +
+
+ ); + + return ( +
+ {(isLoading || isFetching) && } + {!isLoading && + !isFetching && + !isError && + logs.length === 0 && + renderNoLogsFound()} + {isError && !isLoading && !isFetching && } + {!isLoading && !isFetching && !isError && logs.length > 0 && renderContent} +
+ ); +} + +export default SpanLogs; diff --git a/frontend/src/container/SpanDetailsDrawer/SpanLogs/constants.ts b/frontend/src/container/SpanDetailsDrawer/SpanLogs/constants.ts new file mode 100644 index 000000000000..0b8a70ae3b51 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanLogs/constants.ts @@ -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', +}); diff --git a/frontend/src/container/SpanDetailsDrawer/SpanLogs/spanLogs.styles.scss b/frontend/src/container/SpanDetailsDrawer/SpanLogs/spanLogs.styles.scss new file mode 100644 index 000000000000..727b96d14f0b --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanLogs/spanLogs.styles.scss @@ -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); + } + } + } + } + } +} diff --git a/frontend/src/container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs.ts b/frontend/src/container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs.ts new file mode 100644 index 000000000000..39fe55b04735 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs.ts @@ -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; + 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([]); + const [spanLogIds, setSpanLogIds] = useState>(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, + }; +}; diff --git a/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.styles.scss b/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.styles.scss new file mode 100644 index 000000000000..9ead0dc1f8d1 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.styles.scss @@ -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); + } + } + } + } +} diff --git a/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.tsx b/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.tsx new file mode 100644 index 000000000000..449c4e51fb4f --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/SpanRelatedSignals/SpanRelatedSignals.tsx @@ -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 ( +
+
+ {filters.map((filter) => ( +
+ + {filter.key?.key}={filter.value} + +
+ ))} +
+
+ ); +} + +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( + 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 ( + + + + Related Signals - {selectedSpan.name} + + + } + 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={} + > + {selectedSpan && ( +
+
+ + + Logs +
+ ), + value: RelatedSignalsViews.LOGS, + }, + // { + // label: ( + //
+ // + // Metrics + //
+ // ), + // value: RelatedSignalsViews.METRICS, + // }, + // { + // label: ( + //
+ // + // Infra + //
+ // ), + // value: RelatedSignalsViews.INFRA, + // }, + ]} + onChange={handleTabChange} + className="related-signals-radio" + /> + {selectedView === RelatedSignalsViews.LOGS && ( +
+ + {selectedView === RelatedSignalsViews.LOGS && ( + <> + + + + )} + + )} +
+ ); +} + +export default SpanRelatedSignals; diff --git a/frontend/src/container/SpanDetailsDrawer/__tests__/AttributeActions.userflow.test.tsx b/frontend/src/container/SpanDetailsDrawer/__tests__/AttributeActions.userflow.test.tsx index 8bdb9fef6b97..270b6731bc43 100644 --- a/frontend/src/container/SpanDetailsDrawer/__tests__/AttributeActions.userflow.test.tsx +++ b/frontend/src/container/SpanDetailsDrawer/__tests__/AttributeActions.userflow.test.tsx @@ -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} /> - {' '} + , ); diff --git a/frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx b/frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx new file mode 100644 index 000000000000..6004d550ac29 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx @@ -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
{children}
; + }, +); + +// Mock Virtuoso to avoid complex virtualization +jest.mock('react-virtuoso', () => ({ + Virtuoso: jest.fn(({ data, itemContent }) => ( +
+ {data?.map((item: any, index: number) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ )), +})); + +// 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 +
onLogClick?.(data, e)} + > +
{data.body}
+
{data.timestamp}
+
+ ); + }, +); + +// Mock PreferenceContextProvider +jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({ + PreferenceContextProvider: ({ children }: any): JSX.Element => ( +
{children}
+ ), +})); + +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( + + + , + ); + }; + + 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'); + }); +}); diff --git a/frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts b/frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts new file mode 100644 index 000000000000..f542f87149c6 --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/__tests__/mockData.ts @@ -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'`; diff --git a/frontend/src/container/SpanDetailsDrawer/constants.ts b/frontend/src/container/SpanDetailsDrawer/constants.ts new file mode 100644 index 000000000000..b93c431837fc --- /dev/null +++ b/frontend/src/container/SpanDetailsDrawer/constants.ts @@ -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, +}; diff --git a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx index c672e2264997..ad3cc31496b4 100644 --- a/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx +++ b/frontend/src/pages/TraceDetailV2/TraceDetailV2.tsx @@ -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} /> diff --git a/frontend/src/types/api/logs/log.ts b/frontend/src/types/api/logs/log.ts index d40dbb3d2e5d..82ee56bafe64 100644 --- a/frontend/src/types/api/logs/log.ts +++ b/frontend/src/types/api/logs/log.ts @@ -4,6 +4,7 @@ export interface ILog { id: string; traceId: string; spanID: string; + span_id?: string; traceFlags: number; severityText: string; severityNumber: number; diff --git a/frontend/src/types/api/widgets/getQuery.ts b/frontend/src/types/api/widgets/getQuery.ts index 00e8b8612d8c..cb0fab605eab 100644 --- a/frontend/src/types/api/widgets/getQuery.ts +++ b/frontend/src/types/api/widgets/getQuery.ts @@ -5,7 +5,10 @@ export interface PayloadProps { result: QueryData[]; } -export type ListItem = { timestamp: string; data: Omit }; +export type ListItem = { + timestamp: string; + data: Omit; +}; export interface QueryData { lowerBoundSeries?: [number, string][]; diff --git a/frontend/src/utils/logs.ts b/frontend/src/utils/logs.ts index 14e919eb31a5..3ba8bfe2608a 100644 --- a/frontend/src/utils/logs.ts +++ b/frontend/src/utils/logs.ts @@ -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); +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0ce1ca1a5b21..2086f2099db7 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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==