fix: overall improvements to span logs drawer empty state (i.e. trace logs empty state vs. span logs empty state + UI improvements) (#9252)
* chore: remove the applied filters in related signals drawer * chore: make the span logs highlight color more prominent * fix: add label to open trace logs in logs explorer button * feat: improve the span logs empty state i.e. add support for no logs for trace_id * refactor: refactor the span logs content and make it readable * test: add tests for span logs * chore: improve tests * refactor: simplify condition * chore: remove redundant test * fix: make trace_id logs request only if drawer is open * chore: fix failing tests + overall improvements * Update frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: fix the failing test * fix: fix the light mode styles for empty logs component * chore: update the empty state copy * chore: fix the failing tests by updating the assertions with correct empty state copy --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
transition: background-color 2s ease-in;`
|
||||
: ''}
|
||||
|
||||
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||
${({ $isCustomHighlighted }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted)}
|
||||
`;
|
||||
|
||||
export const InfoIconWrapper = styled(Info)`
|
||||
|
||||
@@ -86,6 +86,7 @@ export const REACT_QUERY_KEY = {
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
|
||||
|
||||
// Routing Policies Query Keys
|
||||
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
|
||||
|
||||
@@ -171,3 +171,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.lightMode {
|
||||
.empty-logs-search {
|
||||
&__resources-card {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__resources-description,
|
||||
&__description-list,
|
||||
&__subtitle {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&__clear-filters-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
@@ -30,8 +32,6 @@ 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;
|
||||
@@ -39,29 +39,29 @@ interface SpanLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: 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 => {
|
||||
@@ -236,9 +236,7 @@ function SpanLogs({
|
||||
<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>
|
||||
<span className="no-data-text-2">View logs for the current trace.</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="action-section">
|
||||
@@ -249,24 +247,45 @@ function SpanLogs({
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
Log Explorer
|
||||
View Logs
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
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}
|
||||
{renderSpanLogsContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<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',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: 'in',
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload } from './constants';
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
@@ -20,6 +20,7 @@ interface UseSpanContextLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
@@ -29,6 +30,7 @@ interface UseSpanContextLogsReturn {
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
@@ -110,6 +112,7 @@ export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -264,6 +267,43 @@ export const useSpanContextLogs = ({
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) return null;
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) return null;
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) return true;
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
@@ -277,5 +317,6 @@ export const useSpanContextLogs = ({
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
|
||||
@@ -11,39 +11,20 @@ import {
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
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';
|
||||
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
|
||||
|
||||
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;
|
||||
@@ -66,6 +47,23 @@ function SpanRelatedSignals({
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.traceId,
|
||||
spanId: selectedSpan.spanId,
|
||||
timeRange: {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: isOpen,
|
||||
});
|
||||
|
||||
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
}, []);
|
||||
@@ -75,25 +73,6 @@ function SpanRelatedSignals({
|
||||
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;
|
||||
@@ -146,6 +125,14 @@ function SpanRelatedSignals({
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
@@ -210,23 +197,28 @@ function SpanRelatedSignals({
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
/>
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isFetching={isFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
expectedAfterFilterExpression,
|
||||
expectedBeforeFilterExpression,
|
||||
expectedSpanFilterExpression,
|
||||
expectedTraceOnlyFilterExpression,
|
||||
mockAfterLogsResponse,
|
||||
mockBeforeLogsResponse,
|
||||
mockEmptyLogsResponse,
|
||||
@@ -217,19 +218,22 @@ const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
};
|
||||
|
||||
describe('SpanDetailsDrawer', () => {
|
||||
let apiCallHistory: any[] = [];
|
||||
let apiCallHistory: any = {};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = [];
|
||||
apiCallHistory = {
|
||||
span_logs: null,
|
||||
before_logs: null,
|
||||
after_logs: null,
|
||||
trace_only_logs: null,
|
||||
};
|
||||
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;
|
||||
@@ -238,14 +242,23 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Check for span logs query (contains both trace_id and span_id)
|
||||
if (filterExpression.includes('span_id')) {
|
||||
apiCallHistory.span_logs = query;
|
||||
return Promise.resolve(mockSpanLogsResponse);
|
||||
}
|
||||
// Check for before logs query (contains trace_id and id <)
|
||||
if (filterExpression.includes('id <')) {
|
||||
apiCallHistory.before_logs = query;
|
||||
return Promise.resolve(mockBeforeLogsResponse);
|
||||
}
|
||||
// Check for after logs query (contains trace_id and id >)
|
||||
if (filterExpression.includes('id >')) {
|
||||
apiCallHistory.after_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
// Check for trace only logs query (contains trace_id)
|
||||
if (filterExpression.includes('trace_id =')) {
|
||||
apiCallHistory.trace_only_logs = query;
|
||||
return Promise.resolve(mockAfterLogsResponse);
|
||||
}
|
||||
|
||||
@@ -287,7 +300,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should make three API queries when logs tab is opened', async () => {
|
||||
it('should make 4 API queries when logs tab is opened', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on logs tab to trigger API calls
|
||||
@@ -296,11 +309,16 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
// Verify the three distinct queries were made
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
// Verify the four distinct queries were made
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
trace_only_logs: traceOnlyQuery,
|
||||
} = apiCallHistory;
|
||||
|
||||
// 1. Span logs query (trace_id + span_id)
|
||||
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
@@ -316,6 +334,11 @@ describe('SpanDetailsDrawer', () => {
|
||||
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedAfterFilterExpression,
|
||||
);
|
||||
|
||||
// 4. Trace only logs query (trace_id)
|
||||
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
|
||||
expectedTraceOnlyFilterExpression,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use correct timestamp ordering for different query types', async () => {
|
||||
@@ -327,10 +350,14 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
|
||||
const {
|
||||
span_logs: spanQuery,
|
||||
before_logs: beforeQuery,
|
||||
after_logs: afterQuery,
|
||||
} = apiCallHistory;
|
||||
|
||||
// Verify ordering: span query should use 'desc' (default)
|
||||
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
|
||||
@@ -463,24 +490,6 @@ describe('SpanDetailsDrawer', () => {
|
||||
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();
|
||||
|
||||
@@ -490,7 +499,7 @@ describe('SpanDetailsDrawer', () => {
|
||||
|
||||
// Wait for all API calls to complete first
|
||||
await waitFor(() => {
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
|
||||
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
// Wait for all logs to be rendered - both span logs and context logs
|
||||
|
||||
@@ -12,7 +12,7 @@ export const mockSpan: Span = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
name: TEST_SERVICE,
|
||||
serviceName: TEST_SERVICE,
|
||||
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
|
||||
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
|
||||
durationNano: 1000000000, // 1 second in nanoseconds
|
||||
spanKind: 'server',
|
||||
statusCodeString: 'STATUS_CODE_OK',
|
||||
@@ -207,3 +207,4 @@ export const mockEmptyLogsResponse = {
|
||||
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'`;
|
||||
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;
|
||||
|
||||
@@ -49,12 +49,8 @@ export const getHightLightedLogBackground = (
|
||||
return `background-color: ${orange[3]};`;
|
||||
};
|
||||
|
||||
export const getCustomHighlightBackground = (
|
||||
isHighlighted = false,
|
||||
isDarkMode = true,
|
||||
$logType: string,
|
||||
): string => {
|
||||
export const getCustomHighlightBackground = (isHighlighted = false): string => {
|
||||
if (!isHighlighted) return '';
|
||||
|
||||
return getActiveLogBackground(true, isDarkMode, $logType);
|
||||
return `background-color: ${Color.BG_ROBIN_500}20;`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user