Compare commits

...

16 Commits

Author SHA1 Message Date
amlannandy
96c90440f3 chore: fix CI 2025-09-17 18:00:05 +07:00
amlannandy
4e93d8df57 chore: add evaluation window popover for alerts 2025-09-17 15:35:48 +07:00
primus-bot[bot]
c9568be5d8 chore(release): bump to v0.95.0 (#9112)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-09-17 12:55:46 +05:30
Srikanth Chekuri
1c257f3e14 chore: populate default zero queries for metrics (#9103) 2025-09-17 07:05:38 +00:00
Amlan Kumar Nandy
ff8ac96d37 chore: fix edit alerts page crashing (#9025) 2025-09-17 06:14:25 +00:00
Tushar Vats
e8035b7dd2 Added Download Button to leverage exportRawData API (#9050)
This pull request introduces a new, customizable logs export feature in the Logs Explorer view, replacing the previous hardcoded download functionality. Users can now select export format, row limit, and which columns to include via a dedicated options menu. The implementation includes a new API integration for downloading export data, UI components for export options, and associated styling.
2025-09-17 03:37:44 +00:00
Srikanth Chekuri
cc77b829af chore: recognize dashboard view page for log_comment (#9084) 2025-09-16 19:43:54 +00:00
Nityananda Gohain
49306cbe3d fix: add trace perf improvement only when there is any filter (#9099) 2025-09-16 17:08:58 +00:00
Srikanth Chekuri
233a8e4cc3 chore: use wss usage for workloads listing (#9104) 2025-09-16 16:42:15 +00:00
Srikanth Chekuri
629378bbec chore: do not fail on non-existent signoz_ dbs (#9092) 2025-09-16 16:15:11 +00:00
Shaheer Kochai
d96073f478 feat: highlight the searched spans and dim other spans in trace details v2 (#9032) 2025-09-16 14:29:48 +00:00
Tushar Vats
ba8a49929a Converted format options menu to popover (#9094)
This pull request refactors the LogsFormatOptionsMenu component to use the Ant Design Popover for displaying format options, replacing the previous custom dropdown implementation. It also updates the related styles and removes now-unnecessary state and logic from parent components. The changes improve code maintainability, UI consistency, and simplify event handling.
2025-09-16 13:12:15 +00:00
Shaheer Kochai
a90904951e feat: make the trace details v2 sidebar resizable (#9034)
* feat: make the trace details v2 sidebar resizable

* chore: fix the failing test

* chore: improve the UI while resizing the sidebar
2025-09-16 17:20:32 +04:30
Shaheer Kochai
6c57735a81 feat: add support for span logs (#8857) 2025-09-16 11:22:15 +00:00
Shaheer Kochai
4851527840 fix: fix the incorrect matching spans count (#9033) 2025-09-16 08:56:47 +00:00
Niladri Adhikary
c5051128fa feat: warn when LIKE/ILIKE is used without any %/_ (#9098)
* feat: warn when LIKE/ILIKE is used without any %/_

Signed-off-by: “niladrix719” <niladrix719@gmail.com>
2025-09-16 06:23:26 +00:00
84 changed files with 6295 additions and 851 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.5
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.5
container_name: schema-migrator-async
command:
- async

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.94.1
image: signoz/signoz:v0.95.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,7 +209,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.4
image: signoz/signoz-otel-collector:v0.129.5
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.5
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.94.1
image: signoz/signoz:v0.95.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,7 +150,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.4
image: signoz/signoz-otel-collector:v0.129.5
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.4
image: signoz/signoz-schema-migrator:v0.129.5
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.94.1}
image: signoz/signoz:${VERSION:-v0.95.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.94.1}
image: signoz/signoz:${VERSION:-v0.95.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.5}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +178,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.4}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.5}
container_name: schema-migrator-async
command:
- async

View File

@@ -44,11 +44,13 @@
"@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",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
@@ -137,6 +139,7 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
"style-loader": "1.3.0",
"styled-components": "^5.3.11",

View File

@@ -0,0 +1,64 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { ExportRawDataProps } from 'types/api/exportRawData/getExportRawData';
export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const queryParams = new URLSearchParams();
queryParams.append('start', String(props.start));
queryParams.append('end', String(props.end));
queryParams.append('filter', props.filter);
props.columns.forEach((col) => {
queryParams.append('columns', col);
});
queryParams.append('order_by', props.orderBy);
queryParams.append('limit', String(props.limit));
queryParams.append('format', props.format);
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
responseType: 'blob', // Important: tell axios to handle response as blob
decompress: true, // Enable automatic decompression
headers: {
Accept: 'application/octet-stream', // Tell server we expect binary data
},
timeout: 0,
});
// Only proceed if the response status is 200
if (response.status !== 200) {
throw new Error(
`Failed to download data: server returned status ${response.status}`,
);
}
// Create blob URL from response data
const blob = new Blob([response.data], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// Create and configure download link
const link = document.createElement('a');
link.href = url;
// Get filename from Content-Disposition header or generate timestamped default
const filename =
response.headers['content-disposition']
?.split('filename=')[1]
?.replace(/["']/g, '') || `exported_data.${props.format || 'txt'}`;
link.setAttribute('download', filename);
// Trigger download
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default downloadExportData;

View File

@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
return createTimezoneEntry(normalizedTz, offset);
});
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
export const generateTimezoneData = (
includeEtcTimezones = false,
): Timezone[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
const timezones: Timezone[] = [];

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

@@ -0,0 +1,86 @@
.logs-download-popover {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
background: linear-gradient(
139deg,
var(--bg-ink-400) 0%,
var(--bg-ink-500) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0 8px 12px 8px;
margin: 6px 0;
}
.export-options-container {
width: 240px;
border-radius: 4px;
.title {
display: flex;
color: var(--bg-slate-50);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
margin-bottom: 8px;
}
.export-format,
.row-limit,
.columns-scope {
padding: 12px 4px;
display: flex;
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 13px;
}
}
.horizontal-line {
height: 1px;
background: var(--bg-slate-400);
}
.export-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.lightMode {
.logs-download-popover {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-300);
background: linear-gradient(
139deg,
var(--bg-vanilla-100) 0%,
var(--bg-vanilla-300) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
}
.export-options-container {
.title {
color: var(--bg-ink-200);
}
:global(.ant-radio-wrapper) {
color: var(--bg-ink-400);
}
.horizontal-line {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,341 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DownloadFormats, DownloadRowCounts } from './constants';
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
// Mock antd message
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const TEST_IDS = {
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
} as const;
interface TestProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
const createTestProps = (): TestProps => ({
startTime: 1631234567890,
endTime: 1631234567999,
filter: 'status = 200',
columns: [
{
name: 'http.status',
fieldContext: 'attribute',
fieldDataType: 'int64',
} as TelemetryFieldKey,
],
orderBy: 'timestamp:desc',
});
const testRenderContent = (props: TestProps): void => {
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
};
const testSuccessResponse = (res: any, ctx: any): any =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
ctx.body('id,value\n1,2\n'),
);
describe('LogsDownloadOptionsMenu', () => {
const BASE_URL = ENVIRONMENT.baseURL;
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
let requestSpy: jest.Mock<any, any>;
const setupDefaultServer = (): void => {
server.use(
rest.get(EXPORT_URL, (req, res, ctx) => {
const params = req.url.searchParams;
const payload = {
start: Number(params.get('start')),
end: Number(params.get('end')),
filter: params.get('filter'),
columns: params.getAll('columns'),
order_by: params.get('order_by'),
limit: Number(params.get('limit')),
format: params.get('format'),
};
requestSpy(payload);
return testSuccessResponse(res, ctx);
}),
);
};
// Mock URL.createObjectURL used by download logic
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
requestSpy = jest.fn();
setupDefaultServer();
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
// jsdom doesn't implement it by default
((URL as unknown) as {
createObjectURL: (b: Blob) => string;
}).createObjectURL = jest.fn(() => 'blob:mock');
((URL as unknown) as {
revokeObjectURL: (u: string) => void;
}).revokeObjectURL = jest.fn();
});
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
// restore
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders download button', () => {
const props = createTestProps();
testRenderContent(props);
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
const props = createTestProps();
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: ['attribute.http.status:int64'],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: [],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('handles successful export with success message', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
// Override handler to return 500 for this test
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
'Failed to export logs. Please try again.',
);
});
});
it('handles UI state correctly during export process', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
);
const props = createTestProps();
testRenderContent(props);
// Open popover
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Start export
fireEvent.click(screen.getByText('Export'));
// Check button is disabled during export
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
// Check popover is closed immediately after export starts
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for export to complete and verify button is enabled again
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
});
});
it('uses filename from Content-Disposition and triggers download click', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
ctx.body('row\n'),
),
),
);
const originalCreateElement = document.createElement.bind(document);
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
const clickSpy = jest.spyOn(anchorEl, 'click');
const removeSpy = jest.spyOn(anchorEl, 'remove');
const createElSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: any): any =>
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
);
const appendSpy = jest.spyOn(document.body, 'appendChild');
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
createElSpy.mockRestore();
appendSpy.mockRestore();
});
});

View File

@@ -0,0 +1,170 @@
import './LogsDownloadOptionsMenu.styles.scss';
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DownloadColumnsScopes,
DownloadFormats,
DownloadRowCounts,
} from './constants';
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
return `${prefix}${key.name}${suffix}`;
}
interface LogsDownloadOptionsMenuProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
export default function LogsDownloadOptionsMenu({
startTime,
endTime,
filter,
columns,
orderBy,
}: LogsDownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
const [columnsScope, setColumnsScope] = useState<string>(
DownloadColumnsScopes.ALL,
);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handleExportRawData = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
try {
setIsDownloading(true);
const downloadOptions = {
source: 'logs',
start: startTime,
end: endTime,
columns:
columnsScope === DownloadColumnsScopes.SELECTED
? columns.map((col) => convertTelemetryFieldKeyToText(col))
: [],
filter,
orderBy,
format: exportFormat,
limit: rowLimit,
};
await downloadExportData(downloadOptions);
message.success('Export completed successfully');
} catch (error) {
console.error('Error exporting logs:', error);
message.error('Failed to export logs. Please try again.');
} finally {
setIsDownloading(false);
}
}, [
startTime,
endTime,
columnsScope,
columns,
filter,
orderBy,
exportFormat,
rowLimit,
setIsDownloading,
setIsPopoverOpen,
]);
const popoverContent = useMemo(
() => (
<div
className="export-options-container"
role="dialog"
aria-label="Export options"
aria-modal="true"
>
<div className="export-format">
<Typography.Text className="title">FORMAT</Typography.Text>
<Radio.Group
value={exportFormat}
onChange={(e): void => setExportFormat(e.target.value)}
>
<Radio value={DownloadFormats.CSV}>csv</Radio>
<Radio value={DownloadFormats.JSONL}>jsonl</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="row-limit">
<Typography.Text className="title">Number of Rows</Typography.Text>
<Radio.Group
value={rowLimit}
onChange={(e): void => setRowLimit(e.target.value)}
>
<Radio value={DownloadRowCounts.TEN_K}>10k</Radio>
<Radio value={DownloadRowCounts.THIRTY_K}>30k</Radio>
<Radio value={DownloadRowCounts.FIFTY_K}>50k</Radio>
</Radio.Group>
</div>
<div className="horizontal-line" />
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExportRawData}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
>
Export
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="logs-download-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<DownloadIcon size={15} />
)
}
data-testid="periscope-btn-download-options"
disabled={isDownloading}
/>
</Tooltip>
</Popover>
);
}

View File

@@ -0,0 +1,15 @@
export const DownloadFormats = {
CSV: 'csv',
JSONL: 'jsonl',
};
export const DownloadColumnsScopes = {
ALL: 'all',
SELECTED: 'selected',
};
export const DownloadRowCounts = {
TEN_K: 10_000,
THIRTY_K: 30_000,
FIFTY_K: 50_000,
};

View File

@@ -3,24 +3,30 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import './LogsFormatOptionsMenu.styles.scss';
import { Button, Input, InputNumber, Tooltip, Typography } from 'antd';
import { Button, Input, InputNumber, Popover, Tooltip, Typography } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import cx from 'classnames';
import { LogViewMode } from 'container/LogsTable';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, ChevronLeft, ChevronRight, Minus, Plus, X } from 'lucide-react';
import {
Check,
ChevronLeft,
ChevronRight,
Minus,
Plus,
Sliders,
X,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface LogsFormatOptionsMenuProps {
title: string;
items: any;
selectedOptionFormat: any;
config: OptionsMenuConfig;
}
export default function LogsFormatOptionsMenu({
title,
items,
selectedOptionFormat,
config,
@@ -43,6 +49,7 @@ export default function LogsFormatOptionsMenu({
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const initialMouseEnterRef = useRef<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const onChange = useCallback(
(key: LogViewMode) => {
@@ -202,7 +209,7 @@ export default function LogsFormatOptionsMenu({
};
}, [selectedValue]);
return (
const popoverContent = (
<div
className={cx(
'nested-menu-container',
@@ -344,7 +351,7 @@ export default function LogsFormatOptionsMenu({
</div>
<div className="horizontal-line" />
<div className="menu-container">
<div className="title"> {title} </div>
<div className="title">FORMAT</div>
<div className="menu-items">
{items.map(
@@ -440,4 +447,21 @@ export default function LogsFormatOptionsMenu({
)}
</div>
);
return (
<Popover
content={popoverContent}
trigger="click"
placement="bottomRight"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="format-options-popover"
>
<Button
className="periscope-btn ghost"
icon={<Sliders size={14} />}
data-testid="periscope-btn-format-options"
/>
</Popover>
);
}

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

@@ -0,0 +1,258 @@
/* eslint-disable jsx-a11y/interactive-supports-focus */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Button, Select, Typography } from 'antd';
import classNames from 'classnames';
import { Check } from 'lucide-react';
import { useMemo } from 'react';
import {
EVALUATION_WINDOW_TIMEFRAME,
EVALUATION_WINDOW_TYPE,
} from './constants';
import TimeInput from './TimeInput';
import {
CumulativeWindowTimeframes,
IEvaluationWindowDetailsProps,
IEvaluationWindowPopoverProps,
RollingWindowTimeframes,
} from './types';
import { TIMEZONE_DATA } from './utils';
function EvaluationWindowDetails({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowDetailsProps): JSX.Element {
const currentHourOptions = useMemo(() => {
const options = [];
for (let i = 0; i < 60; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
const currentMonthOptions = useMemo(() => {
const options = [];
for (let i = 1; i <= 31; i++) {
options.push({ label: i.toString(), value: i });
}
return options;
}, []);
if (evaluationWindow.windowType === 'rolling') {
return <div />;
}
const isCurrentHour =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentHour';
const isCurrentDay =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentDay';
const isCurrentMonth =
evaluationWindow.windowType === 'cumulative' &&
evaluationWindow.timeframe === 'currentMonth';
const handleNumberChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: value,
time: evaluationWindow.startingAt.time,
timezone: evaluationWindow.startingAt.timezone,
},
});
};
const handleTimeChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: value,
timezone: evaluationWindow.startingAt.timezone,
},
});
};
const handleTimezoneChange = (value: string): void => {
setEvaluationWindow({
type: 'SET_STARTING_AT',
payload: {
number: evaluationWindow.startingAt.number,
time: evaluationWindow.startingAt.time,
timezone: value,
},
});
};
if (isCurrentHour) {
return (
<div className="evaluation-window-details">
<div className="select-group">
<Typography.Text>STARTING AT MINUTE</Typography.Text>
<Select
options={currentHourOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
</div>
);
}
if (isCurrentDay) {
return (
<div className="evaluation-window-details">
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
if (isCurrentMonth) {
return (
<div className="evaluation-window-details">
<div className="select-group">
<Typography.Text>STARTING ON DAY</Typography.Text>
<Select
options={currentMonthOptions}
value={evaluationWindow.startingAt.number || null}
onChange={handleNumberChange}
placeholder="Select starting at"
/>
</div>
<div className="select-group time-select-group">
<Typography.Text>STARTING AT</Typography.Text>
<TimeInput
value={evaluationWindow.startingAt.time}
onChange={handleTimeChange}
/>
</div>
<div className="select-group">
<Typography.Text>SELECT TIMEZONE</Typography.Text>
<Select
options={TIMEZONE_DATA}
value={evaluationWindow.startingAt.timezone || null}
onChange={handleTimezoneChange}
placeholder="Select timezone"
/>
</div>
</div>
);
}
return <div />;
}
function EvaluationWindowPopover({
evaluationWindow,
setEvaluationWindow,
}: IEvaluationWindowPopoverProps): JSX.Element {
const renderEvaluationWindowContent = (
label: string,
contentOptions: Array<{ label: string; value: string }>,
currentValue: string,
onChange: (value: string) => void,
): JSX.Element => (
<div className="evaluation-window-content-item">
<Typography.Text className="evaluation-window-content-item-label">
{label}
</Typography.Text>
<div className="evaluation-window-content-list">
{contentOptions.map((option) => (
<div
className={classNames('evaluation-window-content-list-item', {
active: currentValue === option.value,
})}
key={option.value}
role="button"
onClick={(): void => onChange(option.value)}
>
<Typography.Text>{option.label}</Typography.Text>
{currentValue === option.value && <Check size={12} />}
</div>
))}
</div>
</div>
);
const renderSelectionContent = (): JSX.Element => {
if (evaluationWindow.windowType === 'rolling') {
return (
<div className="selection-content">
<Typography.Text>
A Rolling Window has a fixed size and shifts its starting point over time
based on when the rules are evaluated.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
if (
evaluationWindow.windowType === 'cumulative' &&
!evaluationWindow.timeframe
) {
return (
<div className="selection-content">
<Typography.Text>
A Cumulative Window has a fixed starting point and expands over time.
</Typography.Text>
<Button type="link">Read the docs</Button>
</div>
);
}
return (
<EvaluationWindowDetails
evaluationWindow={evaluationWindow}
setEvaluationWindow={setEvaluationWindow}
/>
);
};
return (
<div className="evaluation-window-popover">
<div className="evaluation-window-content">
{renderEvaluationWindowContent(
'EVALUATION WINDOW',
EVALUATION_WINDOW_TYPE,
evaluationWindow.windowType,
(value: string): void =>
setEvaluationWindow({
type: 'SET_WINDOW_TYPE',
payload: value as 'rolling' | 'cumulative',
}),
)}
{renderEvaluationWindowContent(
'TIMEFRAME',
EVALUATION_WINDOW_TIMEFRAME[evaluationWindow.windowType],
evaluationWindow.timeframe,
(value: string): void =>
setEvaluationWindow({
type: 'SET_TIMEFRAME',
payload: value as RollingWindowTimeframes | CumulativeWindowTimeframes,
}),
)}
{renderSelectionContent()}
</div>
</div>
);
}
export default EvaluationWindowPopover;

View File

@@ -0,0 +1,51 @@
.time-input-container {
display: flex;
align-items: center;
gap: 0;
.time-input-field {
width: 40px;
height: 32px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 600;
text-align: center;
border-radius: 4px;
&::placeholder {
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
&:disabled {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-400);
cursor: not-allowed;
&:hover {
border-color: var(--bg-slate-400);
}
}
}
.time-input-separator {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 600;
margin: 0 4px;
user-select: none;
}
}

View File

@@ -0,0 +1,155 @@
import './TimeInput.scss';
import { Input } from 'antd';
import React, { useEffect, useState } from 'react';
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
function TimeInput({
value = '00:00:00',
onChange,
disabled = false,
className = '',
}: TimeInputProps): JSX.Element {
const [hours, setHours] = useState('00');
const [minutes, setMinutes] = useState('00');
const [seconds, setSeconds] = useState('00');
// Parse initial value
useEffect(() => {
if (value) {
const timeParts = value.split(':');
if (timeParts.length === 3) {
setHours(timeParts[0].padStart(2, '0'));
setMinutes(timeParts[1].padStart(2, '0'));
setSeconds(timeParts[2].padStart(2, '0'));
}
}
}, [value]);
// Format time value
const formatTimeValue = (h: string, m: string, s: string): string =>
`${h.padStart(2, '0')}:${m.padStart(2, '0')}:${s.padStart(2, '0')}`;
// Handle input change
const handleTimeChange = (
newHours: string,
newMinutes: string,
newSeconds: string,
): void => {
const formattedValue = formatTimeValue(newHours, newMinutes, newSeconds);
onChange?.(formattedValue);
};
// Handle hours change
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newHours = e.target.value.replace(/\D/g, '').slice(0, 2);
setHours(newHours);
handleTimeChange(newHours, minutes, seconds);
};
// Handle minutes change
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newMinutes = e.target.value.replace(/\D/g, '').slice(0, 2);
setMinutes(newMinutes);
handleTimeChange(hours, newMinutes, seconds);
};
// Handle seconds change
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const newSeconds = e.target.value.replace(/\D/g, '').slice(0, 2);
setSeconds(newSeconds);
handleTimeChange(hours, minutes, newSeconds);
};
// Helper functions for field navigation
const getNextField = (current: string): string => {
switch (current) {
case 'hours':
return 'minutes';
case 'minutes':
return 'seconds';
default:
return 'hours';
}
};
const getPrevField = (current: string): string => {
switch (current) {
case 'seconds':
return 'minutes';
case 'minutes':
return 'hours';
default:
return 'seconds';
}
};
// Handle key navigation
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
currentField: 'hours' | 'minutes' | 'seconds',
): void => {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault();
const nextField = document.querySelector(
`[data-field="${getNextField(currentField)}"]`,
) as HTMLInputElement;
nextField?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prevField = document.querySelector(
`[data-field="${getPrevField(currentField)}"]`,
) as HTMLInputElement;
prevField?.focus();
}
};
return (
<div className={`time-input-container ${className}`}>
<Input
data-field="hours"
value={hours}
onChange={handleHoursChange}
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
<span className="time-input-separator">:</span>
<Input
data-field="minutes"
value={minutes}
onChange={handleMinutesChange}
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
<span className="time-input-separator">:</span>
<Input
data-field="seconds"
value={seconds}
onChange={handleSecondsChange}
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
disabled={disabled}
maxLength={2}
className="time-input-field"
/>
</div>
);
}
TimeInput.defaultProps = {
value: '00:00:00',
onChange: undefined,
disabled: false,
className: '',
};
export default TimeInput;

View File

@@ -0,0 +1,3 @@
import TimeInput from './TimeInput';
export default TimeInput;

View File

@@ -0,0 +1,194 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TimeInput from '../TimeInput/TimeInput';
describe('TimeInput', () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default value', () => {
render(<TimeInput />);
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
});
it('should render with provided value', () => {
render(<TimeInput value="12:34:56" />);
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
});
it('should handle value changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '12' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle minutes changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '30' } });
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
});
it('should handle seconds changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '45' } });
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
});
it('should pad single digits with zeros', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '5' } });
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
});
it('should filter non-numeric characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should limit input to 2 characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '123456' } });
expect(hoursInput).toHaveValue('12');
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle keyboard navigation with ArrowRight', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{ArrowRight}');
expect(minutesInput).toHaveFocus();
});
it('should handle keyboard navigation with ArrowLeft', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(minutesInput);
await user.keyboard('{ArrowLeft}');
expect(hoursInput).toHaveFocus();
});
it('should handle Tab navigation', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{Tab}');
expect(minutesInput).toHaveFocus();
});
it('should wrap around navigation from seconds to hours', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const secondsInput = screen.getAllByDisplayValue('00')[2];
await user.click(secondsInput);
await user.keyboard('{ArrowRight}');
expect(hoursInput).toHaveFocus();
});
it('should wrap around navigation from hours to seconds', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const secondsInput = screen.getAllByDisplayValue('00')[2];
await user.click(hoursInput);
await user.keyboard('{ArrowLeft}');
expect(secondsInput).toHaveFocus();
});
it('should apply custom className', () => {
const { container } = render(<TimeInput className="custom-class" />);
expect(container.firstChild).toHaveClass(
'time-input-container',
'custom-class',
);
});
it('should disable inputs when disabled prop is true', () => {
render(<TimeInput disabled />);
const inputs = screen.getAllByRole('textbox');
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});
it('should update internal state when value prop changes', () => {
const { rerender } = render(<TimeInput value="01:02:03" />);
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
rerender(<TimeInput value="04:05:06" />);
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
});
it('should handle malformed time values gracefully', () => {
render(<TimeInput value="invalid:time:format" />);
// Should show the invalid values as they are
expect(screen.getByDisplayValue('invalid')).toBeInTheDocument();
expect(screen.getByDisplayValue('time')).toBeInTheDocument();
expect(screen.getByDisplayValue('format')).toBeInTheDocument();
});
it('should handle partial time values', () => {
render(<TimeInput value="12:34" />);
// Should fall back to default values for incomplete format
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
});
});

View File

@@ -0,0 +1,354 @@
import dayjs from 'dayjs';
import { rrulestr } from 'rrule';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
getCumulativeWindowTimeframeText,
getEvaluationWindowTypeText,
getRollingWindowTimeframeText,
getTimeframeText,
isValidRRule,
} from '../utils';
const MOCK_DATE_STRING = '2024-01-15T10:30:00Z';
const FREQ_DAILY = 'FREQ=DAILY';
const TEN_THIRTY_TIME = '10:30:00';
const NINE_AM_TIME = '09:00:00';
// Mock dayjs
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date?: string | Date) => {
if (date) {
return originalDayjs(date);
}
return originalDayjs(MOCK_DATE_STRING);
});
Object.keys(originalDayjs).forEach((key) => {
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
});
return mockDayjs;
});
jest.mock('rrule', () => ({
rrulestr: jest.fn(),
}));
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
generateTimezoneData: jest.fn().mockReturnValue([]),
}));
describe('utils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getEvaluationWindowTypeText', () => {
it('should return correct text for rolling window', () => {
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
});
it('should return correct text for cumulative window', () => {
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
});
it('should default to Rolling for unknown type', () => {
expect(
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
).toBe('Rolling');
});
});
describe('getCumulativeWindowTimeframeText', () => {
it('should return correct text for current hour', () => {
expect(
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_HOUR),
).toBe('Current hour');
});
it('should return correct text for current day', () => {
expect(
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_DAY),
).toBe('Current day');
});
it('should return correct text for current month', () => {
expect(
getCumulativeWindowTimeframeText(CumulativeWindowTimeframes.CURRENT_MONTH),
).toBe('Current month');
});
it('should default to Current hour for unknown timeframe', () => {
expect(getCumulativeWindowTimeframeText('unknown')).toBe('Current hour');
});
});
describe('getRollingWindowTimeframeText', () => {
it('should return correct text for last 5 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
).toBe('Last 5 minutes');
});
it('should return correct text for last 10 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
).toBe('Last 10 minutes');
});
it('should return correct text for last 15 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
).toBe('Last 15 minutes');
});
it('should return correct text for last 30 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
).toBe('Last 30 minutes');
});
it('should return correct text for last 1 hour', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
).toBe('Last 1 hour');
});
it('should return correct text for last 2 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
).toBe('Last 2 hours');
});
it('should return correct text for last 4 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
).toBe('Last 4 hours');
});
it('should default to Last 5 minutes for unknown timeframe', () => {
expect(
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
).toBe('Last 5 minutes');
});
});
describe('getTimeframeText', () => {
it('should return rolling window text for rolling type', () => {
expect(
getTimeframeText('rolling', RollingWindowTimeframes.LAST_1_HOUR),
).toBe('Last 1 hour');
});
it('should return cumulative window text for cumulative type', () => {
expect(
getTimeframeText('cumulative', CumulativeWindowTimeframes.CURRENT_DAY),
).toBe('Current day');
});
});
describe('buildAlertScheduleFromRRule', () => {
const mockRRule = {
all: jest.fn((callback) => {
const dates = [
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
];
dates.forEach((date, index) => callback(date, index));
}),
};
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
});
it('should return null for empty rrule string', () => {
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
expect(result).toBeNull();
});
it('should build schedule from valid rrule string', () => {
const result = buildAlertScheduleFromRRule(
FREQ_DAILY,
null,
TEN_THIRTY_TIME,
);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toEqual([
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
]);
});
it('should handle rrule with DTSTART', () => {
const date = dayjs('2024-01-20');
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
// When date is provided, DTSTART is automatically added to the rrule string
expect(rrulestr).toHaveBeenCalledWith(
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
);
});
it('should handle rrule without DTSTART', () => {
// Test with no date provided - should use original rrule string
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toHaveLength(3);
});
it('should handle escaped newlines', () => {
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should limit occurrences to maxOccurrences', () => {
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
expect(result).toHaveLength(2);
});
it('should return null on error', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
expect(result).toBeNull();
});
});
describe('buildAlertScheduleFromCustomSchedule', () => {
beforeEach(() => {
// Mock dayjs timezone methods
((dayjs as unknown) as { tz: jest.Mock }).tz = jest.fn(
(date?: string | Date) => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = originalDayjs(date || MOCK_DATE_STRING);
mockDayjs.startOf = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.add = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.date = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.hour = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.minute = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.second = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.daysInMonth = jest.fn().mockReturnValue(31);
mockDayjs.day = jest.fn().mockReturnValue(mockDayjs);
mockDayjs.isAfter = jest.fn().mockReturnValue(true);
mockDayjs.toDate = jest.fn().mockReturnValue(new Date(MOCK_DATE_STRING));
return mockDayjs;
},
);
});
it('should return null for missing required parameters', () => {
expect(
buildAlertScheduleFromCustomSchedule('', [], '10:30:00', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', [], '10:30:00', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', ['monday'], '', 'UTC'),
).toBeNull();
expect(
buildAlertScheduleFromCustomSchedule('week', ['monday'], '10:30:00', ''),
).toBeNull();
});
it('should generate monthly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', '15'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should generate weekly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should filter invalid days for monthly schedule', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', 'invalid', '15'],
'10:30:00',
'UTC',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it('should filter invalid weekdays for weekly schedule', () => {
buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'invalid', 'friday'],
'10:30:00',
'UTC',
5,
);
// Function should handle invalid weekdays gracefully
expect(true).toBe(true);
});
it('should return null on error', () => {
// Test with invalid parameters that should cause an error
const result = buildAlertScheduleFromCustomSchedule(
'invalid_repeat_type',
['monday'],
'10:30:00',
'UTC',
5,
);
// Should return empty array, not null, for invalid repeat type
expect(result).toEqual([]);
});
});
describe('isValidRRule', () => {
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue({});
});
it('should return true for valid rrule', () => {
expect(isValidRRule(FREQ_DAILY)).toBe(true);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
});
it('should handle escaped newlines', () => {
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should return false for invalid rrule', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
expect(isValidRRule('INVALID')).toBe(false);
});
});
});

View File

@@ -0,0 +1,54 @@
export const EVALUATION_WINDOW_TYPE = [
{ label: 'Rolling', value: 'rolling' },
{ label: 'Cumulative', value: 'cumulative' },
];
export const EVALUATION_WINDOW_TIMEFRAME = {
rolling: [
{ label: 'Last 5 minutes', value: '5m0s' },
{ label: 'Last 10 minutes', value: '10m0s' },
{ label: 'Last 15 minutes', value: '15m0s' },
{ label: 'Last 30 minutes', value: '30m0s' },
{ label: 'Last 1 hour', value: '1h0m0s' },
{ label: 'Last 2 hours', value: '2h0m0s' },
{ label: 'Last 4 hours', value: '4h0m0s' },
],
cumulative: [
{ label: 'Current hour', value: 'currentHour' },
{ label: 'Current day', value: 'currentDay' },
{ label: 'Current month', value: 'currentMonth' },
],
};
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
{ label: 'WEEK', value: 'week' },
{ label: 'MONTH', value: 'month' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
{ label: 'SUNDAY', value: 'sunday' },
{ label: 'MONDAY', value: 'monday' },
{ label: 'TUESDAY', value: 'tuesday' },
{ label: 'WEDNESDAY', value: 'wednesday' },
{ label: 'THURSDAY', value: 'thursday' },
{ label: 'FRIDAY', value: 'friday' },
{ label: 'SATURDAY', value: 'saturday' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
{ length: 30 },
(_, i) => {
const value = String(i + 1);
return { label: value, value };
},
);
export const WEEKDAY_MAP: { [key: string]: number } = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};

View File

@@ -0,0 +1,677 @@
.evaluation-settings-container {
margin: 16px;
.evaluate-alert-conditions-container {
display: flex;
align-items: center;
gap: 16px;
background-color: var(--bg-ink-400);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--bg-slate-500);
margin-bottom: 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-size: 14px;
}
.evaluate-alert-conditions-separator {
flex: 1;
height: 1px;
border-top: 1px dashed var(--bg-slate-400);
}
.ant-btn {
display: flex;
align-items: center;
width: 240px;
justify-content: space-between;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.evaluate-alert-conditions-button-left {
color: var(--bg-vanilla-400);
font-size: 12px;
}
.evaluate-alert-conditions-button-right {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
gap: 8px;
.evaluate-alert-conditions-button-right-text {
font-size: 12px;
font-weight: 500;
background-color: var(--bg-slate-400);
padding: 1px 4px;
}
}
}
}
}
.advanced-options-container {
.ant-collapse {
.ant-collapse-item {
.ant-collapse-header {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-500);
.ant-collapse-header-text {
color: var(--bg-vanilla-400);
font-family: Inter;
}
}
.ant-collapse-content {
.ant-collapse-content-box {
background-color: var(--bg-ink-400);
}
}
}
}
.advanced-option-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid var(--bg-slate-500);
.advanced-option-item-left-content {
display: flex;
flex-direction: column;
gap: 6px;
.advanced-option-item-title {
color: var(--bg-vanilla-300);
font-family: Inter;
font-size: 14px;
font-weight: 500;
}
.advanced-option-item-description {
color: var(--bg-vanilla-400);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.advanced-option-item-input {
margin-top: 16px;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&::placeholder {
font-family: 'Space Mono';
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
}
.advanced-option-item-right-content {
display: flex;
align-items: center;
gap: 8px;
.advanced-option-item-input-group {
display: flex;
align-items: center;
.ant-input {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
}
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--bg-vanilla-100);
height: 32px;
border: 1px solid var(--bg-slate-400);
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
}
.ant-select-selection-placeholder {
font-family: 'Space Mono';
}
}
.ant-select-selection-item {
color: var(--bg-vanilla-100);
}
.ant-select-arrow {
color: var(--bg-vanilla-400);
}
}
}
.advanced-option-item-button {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
border-radius: 4px;
}
}
}
}
.ant-popover-arrow {
display: none !important;
}
.ant-popover-content {
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
padding: 0;
margin: 10px;
.ant-popover-inner {
background-color: var(--bg-ink-400);
border: none;
padding: 0;
.evaluation-window-popover {
min-width: 500px;
.evaluation-window-content {
display: flex;
.evaluation-window-content-item {
display: flex;
flex-direction: column;
gap: 8px;
border-right: 1px solid var(--bg-slate-400);
padding: 12px 16px;
min-width: 250px;
min-height: 300px;
.evaluation-window-content-item-label {
color: var(--bg-slate-50);
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
.evaluation-window-content-list {
display: flex;
flex-direction: column;
.evaluation-window-content-list-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 -16px;
padding: 4px 16px;
.ant-typography {
color: var(--bg-vanilla-400);
font-weight: 400;
}
&.active {
background-color: var(--bg-slate-500);
border-left: 2px solid var(--bg-robin-500);
.ant-typography {
font-weight: 500;
color: var(--bg-vanilla-100);
}
}
&:hover {
cursor: pointer;
background-color: var(--bg-slate-500);
}
}
}
}
.selection-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
width: 400px;
.ant-typography {
color: var(--bg-vanilla-400);
}
.ant-btn {
width: fit-content;
}
}
}
.evaluation-window-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
background-color: var(--bg-ink-300);
border-top: 1px solid var(--bg-slate-400);
padding: 16px;
}
.ant-btn {
background-color: var(--bg-ink-200);
border: 1px solid var(--bg-slate-200);
color: var(--bg-vanilla-400);
font-size: 14px;
}
}
}
}
.evaluation-window-details {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 400px;
min-height: 300px;
padding: 16px;
.select-group {
display: flex;
flex-direction: column;
gap: 2px;
.ant-typography {
color: var(--bg-slate-50);
font-size: 11px;
line-height: 18px;
font-weight: 500;
}
}
.time-select-group {
.ant-input-group {
flex-direction: row;
gap: 8px;
.ant-select {
width: 40px;
}
}
}
.ant-typography {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 500;
}
.ant-select {
width: 60%;
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
height: 32px;
}
&:hover {
border-color: var(--bg-ink-400);
}
}
}
.evaluation-cadence-container {
border-bottom: 1px solid var(--bg-slate-500);
.evaluation-cadence-item {
border-bottom: none !important;
}
.edit-custom-schedule {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
.highlight {
background-color: var(--bg-slate-500);
padding: 4px 8px;
border-radius: 4px;
color: var(--bg-vanilla-400);
font-weight: 500;
margin: 0 4px;
font-size: 14px;
}
}
.ant-btn-group {
.ant-btn {
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-400);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
.evaluation-cadence-details {
margin: 16px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--bg-slate-500);
.evaluation-cadence-details-title {
color: var(--bg-vanilla-100);
font-size: 14px;
font-weight: 500;
padding-left: 16px;
padding-top: 16px;
}
.query-section-tabs {
display: flex;
align-items: center;
.query-section-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 0.5px solid var(--bg-slate-400);
border-bottom: 0.5px solid var(--bg-slate-400);
width: 120px;
height: 36px;
gap: 8px;
&.active-tab {
background-color: var(--bg-ink-500);
border-bottom: none;
&:hover {
background-color: var(--bg-ink-500) !important;
}
}
&:disabled {
background-color: var(--bg-ink-300);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--bg-vanilla-100);
}
}
}
}
.evaluation-cadence-details-content {
display: flex;
gap: 16px;
border-top: 1px solid var(--bg-slate-500);
padding: 16px;
.evaluation-cadence-details-content-row {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
height: 500px;
overflow-y: scroll;
padding-right: 16px;
.editor-view,
.rrule-view {
display: flex;
flex-direction: column;
gap: 16px;
textarea {
height: 200px;
background: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
border-radius: 4px;
color: var(--bg-vanilla-400) !important;
font-family: 'Space Mono';
font-size: 14px;
&::placeholder {
font-family: 'Space Mono';
color: var(--bg-vanilla-400) !important;
}
}
.select-group {
display: flex;
flex-direction: column;
gap: 4px;
.ant-typography {
color: var(--bg-vanilla-100);
font-size: 13px;
font-weight: 500;
}
.ant-select {
border: 1px solid var(--bg-slate-400);
.ant-select-selector {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
}
}
.ant-picker {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-400);
.ant-picker-input {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-100);
}
}
}
}
.buttons-row {
display: flex;
align-items: center;
gap: 16px;
margin-top: 16px;
}
.no-schedule {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
height: 100%;
color: var(--bg-vanilla-100);
font-size: 14px;
}
.schedule-preview {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
.schedule-preview-header {
display: flex;
align-items: center;
gap: 8px;
.schedule-preview-title {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 500;
}
}
.schedule-preview-list {
display: flex;
flex-direction: column;
gap: 0;
.schedule-preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
.schedule-preview-timeline {
display: flex;
flex-direction: column;
align-items: center;
min-width: 20px;
.schedule-preview-timeline-line {
width: 1px;
height: 20px;
background-color: var(--bg-slate-400);
}
}
.schedule-preview-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.schedule-preview-date {
color: var(--bg-vanilla-300);
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
.schedule-preview-separator {
flex: 1;
height: 1px;
border-top: 1px dashed var(--bg-slate-400);
}
.schedule-preview-timezone {
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
}
}
}
}
}
}
}
}
.ant-picker-date-panel {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-layout {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}
.ant-picker-date-panel-header {
background-color: var(--bg-ink-300);
border: 1px solid var(--bg-slate-300);
}

View File

@@ -0,0 +1,53 @@
import { Dispatch, SetStateAction } from 'react';
import {
EvaluationWindowAction,
EvaluationWindowState,
} from '../context/types';
export interface IAdvancedOptionItemProps {
title: string;
description: string;
input: JSX.Element;
}
export enum RollingWindowTimeframes {
'LAST_5_MINUTES' = '5m0s',
'LAST_10_MINUTES' = '10m0s',
'LAST_15_MINUTES' = '15m0s',
'LAST_30_MINUTES' = '30m0s',
'LAST_1_HOUR' = '1h0m0s',
'LAST_2_HOURS' = '2h0m0s',
'LAST_4_HOURS' = '4h0m0s',
}
export enum CumulativeWindowTimeframes {
'CURRENT_HOUR' = 'currentHour',
'CURRENT_DAY' = 'currentDay',
'CURRENT_MONTH' = 'currentMonth',
}
export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationWindowDetailsProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
}
export interface IEvaluationCadenceDetailsProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}

View File

@@ -0,0 +1,248 @@
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { rrulestr } from 'rrule';
import { WEEKDAY_MAP } from './constants';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
// Extend dayjs with timezone plugins
dayjs.extend(utc);
dayjs.extend(timezone);
export const getEvaluationWindowTypeText = (
windowType: 'rolling' | 'cumulative',
): string => {
switch (windowType) {
case 'rolling':
return 'Rolling';
case 'cumulative':
return 'Cumulative';
default:
return 'Rolling';
}
};
export const getCumulativeWindowTimeframeText = (timeframe: string): string => {
switch (timeframe) {
case CumulativeWindowTimeframes.CURRENT_HOUR:
return 'Current hour';
case CumulativeWindowTimeframes.CURRENT_DAY:
return 'Current day';
case CumulativeWindowTimeframes.CURRENT_MONTH:
return 'Current month';
default:
return 'Current hour';
}
};
export const getRollingWindowTimeframeText = (
timeframe: RollingWindowTimeframes,
): string => {
switch (timeframe) {
case RollingWindowTimeframes.LAST_5_MINUTES:
return 'Last 5 minutes';
case RollingWindowTimeframes.LAST_10_MINUTES:
return 'Last 10 minutes';
case RollingWindowTimeframes.LAST_15_MINUTES:
return 'Last 15 minutes';
case RollingWindowTimeframes.LAST_30_MINUTES:
return 'Last 30 minutes';
case RollingWindowTimeframes.LAST_1_HOUR:
return 'Last 1 hour';
case RollingWindowTimeframes.LAST_2_HOURS:
return 'Last 2 hours';
case RollingWindowTimeframes.LAST_4_HOURS:
return 'Last 4 hours';
default:
return 'Last 5 minutes';
}
};
export const getTimeframeText = (
windowType: 'rolling' | 'cumulative',
timeframe: string,
): string => {
if (windowType === 'rolling') {
return getRollingWindowTimeframeText(timeframe as RollingWindowTimeframes);
}
return getCumulativeWindowTimeframeText(timeframe);
};
export function buildAlertScheduleFromRRule(
rruleString: string,
date: Dayjs | null,
startAt: string,
maxOccurrences = 10,
): Date[] | null {
try {
if (!rruleString) return null;
// Handle literal \n in string
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
if (date) {
const dt = dayjs(date);
if (!dt.isValid()) throw new Error('Invalid date provided');
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
const dtWithTime = dt
.set('hour', hours)
.set('minute', minutes)
.set('second', seconds)
.set('millisecond', 0);
const dtStartStr = dtWithTime
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
if (!/DTSTART/i.test(finalRRuleString)) {
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
}
}
const rruleObj = rrulestr(finalRRuleString);
const occurrences: Date[] = [];
rruleObj.all((date, index) => {
if (index >= maxOccurrences) return false;
occurrences.push(date);
return true;
});
return occurrences;
} catch (error) {
console.error('Error building RRULE:', error);
return null;
}
}
function generateMonthlyOccurrences(
targetDays: number[],
hours: number,
minutes: number,
seconds: number,
timezone: string,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentMonth = dayjs().tz(timezone).startOf('month');
Array.from({ length: maxOccurrences }).forEach((_, monthOffset) => {
const monthDate = currentMonth.add(monthOffset, 'month');
targetDays.forEach((day) => {
if (occurrences.length >= maxOccurrences) return;
const daysInMonth = monthDate.daysInMonth();
if (day <= daysInMonth) {
const targetDate = monthDate
.date(day)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(dayjs().tz(timezone))) {
occurrences.push(targetDate.toDate());
}
}
});
});
return occurrences;
}
function generateWeeklyOccurrences(
targetWeekdays: number[],
hours: number,
minutes: number,
seconds: number,
timezone: string,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentWeek = dayjs().tz(timezone).startOf('week');
Array.from({ length: maxOccurrences }).forEach((_, weekOffset) => {
const weekDate = currentWeek.add(weekOffset, 'week');
targetWeekdays.forEach((weekday) => {
if (occurrences.length >= maxOccurrences) return;
const targetDate = weekDate
.day(weekday)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(dayjs().tz(timezone))) {
occurrences.push(targetDate.toDate());
}
});
});
return occurrences;
}
export function buildAlertScheduleFromCustomSchedule(
repeatEvery: string,
occurence: string[],
startAt: string,
timezone: string,
maxOccurrences = 10,
): Date[] | null {
try {
if (!repeatEvery || !occurence.length || !startAt || !timezone) {
return null;
}
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
let occurrences: Date[] = [];
if (repeatEvery === 'month') {
const targetDays = occurence
.map((day) => parseInt(day, 10))
.filter((day) => !Number.isNaN(day));
occurrences = generateMonthlyOccurrences(
targetDays,
hours,
minutes,
seconds,
timezone,
maxOccurrences,
);
} else if (repeatEvery === 'week') {
const targetWeekdays = occurence
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
.filter((day) => day !== undefined);
occurrences = generateWeeklyOccurrences(
targetWeekdays,
hours,
minutes,
seconds,
timezone,
maxOccurrences,
);
}
occurrences.sort((a, b) => a.getTime() - b.getTime());
return occurrences.slice(0, maxOccurrences);
} catch (error) {
console.error('Error building custom schedule:', error);
return null;
}
}
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));
export function isValidRRule(rruleString: string): boolean {
try {
// normalize escaped \n
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
rrulestr(finalRRuleString); // will throw if invalid
return true;
} catch {
return false;
}
}

View File

@@ -1,13 +1,17 @@
import { Color } from '@signozhq/design-tokens';
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/utils';
import dayjs from 'dayjs';
import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
Algorithm,
EvaluationWindowState,
Seasonality,
Threshold,
TimeDuration,
@@ -70,6 +74,48 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
thresholds: [INITIAL_CRITICAL_THRESHOLD],
};
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 0,
timeUnit: '',
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
},
delayEvaluation: {
delay: 0,
timeUnit: '',
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: 'm',
},
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
rrule: {
date: dayjs(),
startAt: '00:00:00',
rrule: '',
},
},
};
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
time: '00:00:00',
number: '1',
timezone: TIMEZONE_DATA[0].value,
},
};
export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
@@ -115,3 +161,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
{ value: Seasonality.DAILY, label: 'Daily' },
{ value: Seasonality.WEEKLY, label: 'Weekly' },
];
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: 's', label: 'Second' },
{ value: 'm', label: 'Minute' },
{ value: 'h', label: 'Hours' },
{ value: 'd', label: 'Day' },
];

View File

@@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import {
advancedOptionsReducer,
alertCreationReducer,
alertThresholdReducer,
buildInitialAlertDef,
evaluationWindowReducer,
getInitialAlertTypeFromURL,
} from './utils';
@@ -80,6 +84,16 @@ export function CreateAlertProvider(
INITIAL_ALERT_THRESHOLD_STATE,
);
const [evaluationWindow, setEvaluationWindow] = useReducer(
evaluationWindowReducer,
INITIAL_EVALUATION_WINDOW_STATE,
);
const [advancedOptions, setAdvancedOptions] = useReducer(
advancedOptionsReducer,
INITIAL_ADVANCED_OPTIONS_STATE,
);
useEffect(() => {
setThresholdState({
type: 'RESET',
@@ -94,8 +108,19 @@ export function CreateAlertProvider(
setAlertType: handleAlertTypeChange,
thresholdState,
setThresholdState,
evaluationWindow,
setEvaluationWindow,
advancedOptions,
setAdvancedOptions,
}),
[alertState, alertType, handleAlertTypeChange, thresholdState],
[
alertState,
alertType,
handleAlertTypeChange,
thresholdState,
evaluationWindow,
advancedOptions,
],
);
return (

View File

@@ -1,3 +1,4 @@
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Labels } from 'types/api/alerts/def';
@@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
setAlertType: Dispatch<AlertTypes>;
thresholdState: AlertThresholdState;
setThresholdState: Dispatch<AlertThresholdAction>;
advancedOptions: AdvancedOptionsState;
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
}
export interface ICreateAlertProviderProps {
@@ -101,3 +106,86 @@ export type AlertThresholdAction =
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'RESET' };
export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: {
toleranceLimit: number;
timeUnit: string;
};
enforceMinimumDatapoints: {
minimumDatapoints: number;
};
delayEvaluation: {
delay: number;
timeUnit: string;
};
evaluationCadence: {
mode: EvaluationCadenceMode;
default: {
value: number;
timeUnit: string;
};
custom: {
repeatEvery: string;
startAt: string;
occurence: string[];
timezone: string;
};
rrule: {
date: Dayjs | null;
startAt: string;
rrule: string;
};
};
}
export type AdvancedOptionsAction =
| {
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string };
}
| {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number };
}
| {
type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string };
}
| {
type: 'SET_EVALUATION_CADENCE';
payload: {
default: { value: number; timeUnit: string };
custom: {
repeatEvery: string;
startAt: string;
timezone: string;
occurence: string[];
};
rrule: { date: Dayjs | null; startAt: string; rrule: string };
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export interface EvaluationWindowState {
windowType: 'rolling' | 'cumulative';
timeframe: string;
startingAt: {
time: string;
number: string;
timezone: string;
};
}
export type EvaluationWindowAction =
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
| { type: 'SET_TIMEFRAME'; payload: string }
| {
type: 'SET_STARTING_AT';
payload: { time: string; number: string; timezone: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';

View File

@@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import {
AdvancedOptionsAction,
AdvancedOptionsState,
AlertState,
AlertThresholdAction,
AlertThresholdState,
CreateAlertAction,
EvaluationWindowAction,
EvaluationWindowState,
} from './types';
export const alertCreationReducer = (
@@ -110,3 +118,57 @@ export const alertThresholdReducer = (
return state;
}
};
export const advancedOptionsReducer = (
state: AdvancedOptionsState,
action: AdvancedOptionsAction,
): AdvancedOptionsState => {
switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return { ...state, sendNotificationIfDataIsMissing: action.payload };
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return { ...state, enforceMinimumDatapoints: action.payload };
case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
};
case 'SET_EVALUATION_CADENCE_MODE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
return state;
}
};
export const evaluationWindowReducer = (
state: EvaluationWindowState,
action: EvaluationWindowAction,
): EvaluationWindowState => {
switch (action.type) {
case 'SET_WINDOW_TYPE':
return {
...state,
windowType: action.payload,
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
timeframe:
action.payload === 'rolling'
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
: 'currentHour',
};
case 'SET_TIMEFRAME':
return { ...state, timeframe: action.payload };
case 'SET_STARTING_AT':
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
default:
return state;
}
};

View File

@@ -31,7 +31,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -105,7 +105,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Utilization (bytes)</div>,
title: <div className="column-header-left">Memory Utilization (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -113,7 +113,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Allocatable (bytes)</div>,
title: <div className="column-header-left">Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

View File

@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -211,10 +211,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -203,10 +203,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left small-col">Mem Usage</div>,
title: <div className="column-header-left small-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
sorter: true,
align: 'left',
},

View File

@@ -84,7 +84,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -238,10 +238,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header small-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -99,10 +99,10 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Mem Usage</div>,
title: <div className="column-header-left">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
sorter: true,
align: 'left',
},

View File

@@ -37,7 +37,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Memory Usage (bytes)',
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -121,7 +121,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Usage (bytes)</div>,
title: <div className="column-header-left">Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
@@ -129,7 +129,7 @@ const columnsConfig = [
align: 'left',
},
{
title: <div className="column-header-left">Memory Alloc (bytes)</div>,
title: <div className="column-header-left">Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,

View File

@@ -72,7 +72,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -211,10 +211,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header small-col">Mem Usage</div>,
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -69,7 +69,7 @@ export const defaultAddedColumns: IEntityColumn[] = [
canRemove: false,
},
{
label: 'Mem Usage',
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canRemove: false,
@@ -207,10 +207,10 @@ const columnsConfig = [
className: `column ${columnProgressBarClassName}`,
},
{
title: <div className="column-header">Mem Usage</div>,
title: <div className="column-header med-col">Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
width: 120,
ellipsis: true,
sorter: true,
align: 'left',

View File

@@ -240,7 +240,6 @@ function LiveLogsContainer(): JSX.Element {
{showFormatMenuItems && (
<LogsFormatOptionsMenu
title="FORMAT"
items={formatItems}
selectedOptionFormat={options.format}
config={config}

View File

@@ -1,15 +1,13 @@
import { Button, Switch, Typography } from 'antd';
import { Switch, Typography } from 'antd';
import { WsDataEvent } from 'api/common/getQueryStats';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
import { PANEL_TYPES } from 'constants/queryBuilder';
import Download from 'container/DownloadV2/DownloadV2';
import { useOptionsMenu } from 'container/OptionsMenu';
import useClickOutside from 'hooks/useClickOutside';
import { ArrowUp10, Minus, Sliders } from 'lucide-react';
import { useRef, useState } from 'react';
import { ArrowUp10, Minus } from 'lucide-react';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import QueryStatus from './QueryStatus';
@@ -22,11 +20,12 @@ function LogsActionsContainer({
handleToggleFrequencyChart,
orderBy,
setOrderBy,
flattenLogData,
isFetching,
isLoading,
isError,
isSuccess,
minTime,
maxTime,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -34,16 +33,14 @@ function LogsActionsContainer({
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
flattenLogData: any;
isFetching: boolean;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
queryStats: WsDataEvent | undefined;
minTime: number;
maxTime: number;
}): JSX.Element {
const [showFormatMenuItems, setShowFormatMenuItems] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
@@ -71,18 +68,6 @@ function LogsActionsContainer({
},
];
const handleToggleShowFormatOptions = (): void =>
setShowFormatMenuItems(!showFormatMenuItems);
useClickOutside({
ref: menuRef,
onClickOutside: () => {
if (showFormatMenuItems) {
setShowFormatMenuItems(false);
}
},
});
return (
<div className="logs-actions-container">
<div className="tab-options">
@@ -114,27 +99,21 @@ function LogsActionsContainer({
dataSource={DataSource.LOGS}
/>
</div>
<Download
data={flattenLogData}
isLoading={isFetching}
fileName="log_data"
/>
<div className="format-options-container" ref={menuRef}>
<Button
className="periscope-btn ghost"
onClick={handleToggleShowFormatOptions}
icon={<Sliders size={14} />}
data-testid="periscope-btn"
<div className="download-options-container">
<LogsDownloadOptionsMenu
startTime={minTime}
endTime={maxTime}
filter={listQuery?.filter?.expression || ''}
columns={config.addColumn?.value || []}
orderBy={orderBy}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
{showFormatMenuItems && (
<LogsFormatOptionsMenu
title="FORMAT"
items={formatItems}
selectedOptionFormat={options.format}
config={config}
/>
)}
</div>
</>
)}

View File

@@ -6,7 +6,6 @@ import setToLocalstorage from 'api/browser/localstorage/set';
import { getQueryStats, WsDataEvent } from 'api/common/getQueryStats';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { QueryParams } from 'constants/query';
@@ -25,7 +24,6 @@ import LogsExplorerChart from 'container/LogsExplorerChart';
import LogsExplorerList from 'container/LogsExplorerList';
import LogsExplorerTable from 'container/LogsExplorerTable';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import dayjs from 'dayjs';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
@@ -33,19 +31,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { FlatLogData } from 'lib/logs/flatLogData';
import { getPaginationQueryDataV2 } from 'lib/newQueryBuilder/getPaginationQueryData';
import {
cloneDeep,
defaultTo,
isEmpty,
isUndefined,
omit,
set,
} from 'lodash-es';
import { cloneDeep, defaultTo, isEmpty, isUndefined, set } from 'lodash-es';
import LiveLogs from 'pages/LiveLogs';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { useTimezone } from 'providers/Timezone';
import {
Dispatch,
memo,
@@ -607,29 +596,6 @@ function LogsExplorerViewsContainer({
setIsLoadingQueries,
]);
const { timezone } = useTimezone();
const flattenLogData = useMemo(
() =>
logs.map((log) => {
const timestamp =
typeof log.timestamp === 'string'
? dayjs(log.timestamp)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(log.timestamp / 1e6)
.tz(timezone.value)
.format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return FlatLogData({
timestamp,
body: log.body,
...omit(log, 'timestamp', 'body'),
});
}),
[logs, timezone.value],
);
const handleToggleFrequencyChart = useCallback(() => {
const newShowFrequencyChart = !showFrequencyChart;
@@ -654,11 +620,12 @@ function LogsExplorerViewsContainer({
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
flattenLogData={flattenLogData}
isFetching={isFetching}
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
minTime={minTime}
maxTime={maxTime}
/>
)}

View File

@@ -185,13 +185,16 @@ describe('LogsExplorerViews -', () => {
lodsQueryServerRequest();
const { queryByTestId } = renderer();
const periscopeButtonTestId = 'periscope-btn';
const periscopeDownloadButtonTestId = 'periscope-btn-download-options';
const periscopeFormatButtonTestId = 'periscope-btn-format-options';
// Test that the periscope button is present
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
// Test that the menu opens when clicked
fireEvent.click(queryByTestId(periscopeButtonTestId) as HTMLElement);
fireEvent.click(queryByTestId(periscopeDownloadButtonTestId) as HTMLElement);
fireEvent.click(queryByTestId(periscopeFormatButtonTestId) as HTMLElement);
expect(document.querySelector('.menu-container')).toBeInTheDocument();
// Test that the menu items are present
@@ -200,7 +203,8 @@ describe('LogsExplorerViews -', () => {
expect(menuItems.length).toBe(expectedMenuItemsCount);
// Test that the component renders without crashing
expect(queryByTestId(periscopeButtonTestId)).toBeInTheDocument();
expect(queryByTestId(periscopeDownloadButtonTestId)).toBeInTheDocument();
expect(queryByTestId(periscopeFormatButtonTestId)).toBeInTheDocument();
});
it('check isLoading state', async () => {

View File

@@ -163,12 +163,11 @@ export const GroupByFilter = memo(function GroupByFilter({
}, []);
useEffect(() => {
const currentValues: SelectOption<string, string>[] = query.groupBy.map(
(item) => ({
const currentValues: SelectOption<string, string>[] =
query.groupBy?.map((item) => ({
label: `${item.key}`,
value: `${item.id}`,
}),
);
})) || [];
setLocalValues(currentValues);
}, [query]);
@@ -191,7 +190,7 @@ export const GroupByFilter = memo(function GroupByFilter({
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
data-testid="group-by"
placeholder={localValues.length === 0 ? 'Everything (no breakdown)' : ''}
placeholder={localValues?.length === 0 ? 'Everything (no breakdown)' : ''}
/>
);
});

View File

@@ -1,9 +1,12 @@
.span-details-drawer {
display: flex;
flex-direction: column;
width: 450px;
height: calc(100vh - 44px); //44px -> trace details top bar
border-left: 1px solid var(--bg-slate-400);
overflow-y: auto;
&:not(&-docked) {
min-width: 450px;
}
&::-webkit-scrollbar {
width: 0.1rem;
@@ -124,24 +127,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 {
@@ -217,11 +202,21 @@
.span-details-drawer-docked {
width: 48px;
flex: 0 48px !important;
.header {
justify-content: center;
}
}
.resizable-handle {
box-sizing: border-box;
border: 2px solid transparent;
&:hover,
&[data-resize-handle-state='drag'],
&[data-resize-handle-state='hover'] {
border-color: rgba(35, 196, 248, 0.2);
}
}
.lightMode {
.span-details-drawer {
@@ -268,10 +263,6 @@
}
}
.related-logs {
color: var(--bg-ink-400);
}
.attributes-events {
.details-drawer-tabs {
.ant-tabs-nav::before {

View File

@@ -1,30 +1,27 @@
import './SpanDetailsDrawer.styles.scss';
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import { RadioChangeEvent } from 'antd/lib';
import LogsIcon from 'assets/AlertHistory/LogsIcon';
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 +32,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,27 +113,9 @@ 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
className={cx(
'span-details-drawer',
isSpanDetailsDocked ? 'span-details-drawer-docked' : '',
)}
>
<>
<section className="header">
{!isSpanDetailsDocked && (
<div className="heading">
@@ -216,12 +210,31 @@ 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,
},
]}
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,7 +253,19 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</section>
</>
)}
</div>
{selectedSpan && (
<SpanRelatedSignals
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
isOpen={isRelatedSignalsOpen}
onClose={handleRelatedSignalsClose}
initialView={activeDrawerView}
key={activeDrawerView}
/>
)}
</>
);
}

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

@@ -7,6 +7,7 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
@@ -53,20 +54,32 @@ function Filters({
startTime,
endTime,
traceID,
onFilteredSpansChange = (): void => {},
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const handleFilterChange = (value: TagFilter): void => {
setFilters(value);
};
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
const { search } = useLocation();
const history = useHistory();
@@ -115,16 +128,22 @@ function Filters({
queryKey: [filters],
enabled: filters.items.length > 0,
onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) {
const spanIds = data?.payload.data.newResult.data.result[0].list.map(
(val) => val.data.spanID,
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
}
},
@@ -180,4 +199,8 @@ function Filters({
);
}
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters;

View File

@@ -315,7 +315,8 @@
}
}
.interested-span {
.interested-span,
.selected-non-matching-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
@@ -323,6 +324,20 @@
background: unset;
}
}
.dimmed-span {
opacity: 0.4;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.span-overview-content,
.span-line-text {
opacity: 0.5;
}
}
}
.div-td + .div-td {

View File

@@ -67,6 +67,8 @@ function SpanOverview({
setSelectedSpan,
handleAddSpanToFunnel,
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
}: {
span: Span;
@@ -75,6 +77,8 @@ function SpanOverview({
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
}): JSX.Element {
const isRootSpan = span.level === 0;
@@ -85,13 +89,23 @@ function SpanOverview({
color = `var(--bg-cherry-500)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx(
'span-overview',
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
)}
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
style={{
paddingLeft: `${
isRootSpan
@@ -199,11 +213,15 @@ export function SpanDuration({
traceMetadata,
setSelectedSpan,
selectedSpan,
filteredSpanIds,
isFilterActive,
}: {
span: Span;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
@@ -224,6 +242,13 @@ export function SpanDuration({
const [hasActionButtons, setHasActionButtons] = useState(false);
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
const handleMouseEnter = (): void => {
setHasActionButtons(true);
};
@@ -256,10 +281,12 @@ export function SpanDuration({
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx(
'span-duration',
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
)}
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(): void => {
@@ -325,14 +352,17 @@ function getWaterfallColumns({
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}: {
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
uncollapsedNodes: string[];
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
handleAddSpanToFunnel: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): ColumnDef<Span, any>[] {
const waterfallColumns: ColumnDef<Span, any>[] = [
columnDefHelper.display({
@@ -347,6 +377,8 @@ function getWaterfallColumns({
setSelectedSpan={setSelectedSpan}
handleAddSpanToFunnel={handleAddSpanToFunnel}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
size: 450,
@@ -371,6 +403,8 @@ function getWaterfallColumns({
traceMetadata={traceMetadata}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
),
}),
@@ -390,8 +424,19 @@ function Success(props: ISuccessProps): JSX.Element {
setSelectedSpan,
selectedSpan,
} = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
@@ -443,6 +488,8 @@ function Success(props: ISuccessProps): JSX.Element {
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
}),
[
handleCollapseUncollapse,
@@ -451,6 +498,8 @@ function Success(props: ISuccessProps): JSX.Element {
selectedSpan,
setSelectedSpan,
handleAddSpanToFunnel,
filteredSpanIds,
isFilterActive,
],
);
@@ -508,6 +557,7 @@ function Success(props: ISuccessProps): JSX.Element {
startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/>
<TableV3
columns={columns}

View File

@@ -6,6 +6,14 @@ import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks
jest.mock('hooks/useSafeNavigate');
jest.mock('hooks/useUrlQuery');
@@ -87,11 +95,13 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText('1.16 ms');
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement);
// Verify setSelectedSpan was called with the correct span
@@ -113,10 +123,12 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText('1.16 ms');
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument();
@@ -139,10 +151,135 @@ describe('SpanDuration', () => {
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText('1.16 ms').closest('.span-duration');
expect(spanElement).toHaveClass('interested-span');
// eslint-disable-next-line sonarjs/no-duplicate-string
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies highlighted-span class when span matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies dimmed-span class when span does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('dims span when filter is active but no matches found', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
});

View File

@@ -12,6 +12,12 @@ jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(),
}));
jest.mock('@signozhq/resizable', () => ({
ResizableHandle: jest.fn(),
ResizablePanel: jest.fn(),
ResizablePanelGroup: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({

View File

@@ -1,7 +1,13 @@
import './TraceDetailV2.styles.scss';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@signozhq/resizable';
import { Button, Tabs } from 'antd';
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
import cx from 'classnames';
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
import SpanDetailsDrawer from 'container/SpanDetailsDrawer/SpanDetailsDrawer';
import TraceMetadata from 'container/TraceMetadata/TraceMetadata';
@@ -121,11 +127,8 @@ function TraceDetailsV2(): JSX.Element {
];
return (
<div className="trace-layout">
<div
className="trace-left-content"
style={{ width: `calc(100% - ${isSpanDetailsDocked ? 48 : 330}px)` }}
>
<ResizablePanelGroup direction="horizontal" autoSaveId="trace-drawer">
<ResizablePanel minSize={20} maxSize={80} className="trace-left-content">
<TraceMetadata
traceID={traceId}
duration={
@@ -144,16 +147,27 @@ function TraceDetailsV2(): JSX.Element {
) : (
<NoData />
)}
</div>
<SpanDetailsDrawer
isSpanDetailsDocked={isSpanDetailsDocked}
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
selectedSpan={selectedSpan}
traceID={traceId}
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle className="resizable-handle" />
<ResizablePanel
defaultSize={20}
minSize={20}
maxSize={50}
className={cx('span-details-drawer', {
'span-details-drawer-docked': isSpanDetailsDocked,
})}
>
<SpanDetailsDrawer
isSpanDetailsDocked={isSpanDetailsDocked}
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
selectedSpan={selectedSpan}
traceStartTime={traceData?.payload?.startTimestampMillis || 0}
traceEndTime={traceData?.payload?.endTimestampMillis || 0}
/>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View File

@@ -0,0 +1,10 @@
export interface ExportRawDataProps {
source: string;
format: string;
start: number;
end: number;
columns: string[];
filter: string;
orderBy: string;
limit: number;
}

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

@@ -34,16 +34,18 @@ export const convertBuilderQueryToIBuilderQuery = (
queryName: builderQuery.name,
dataSource,
legend: builderQuery.legend,
groupBy: builderQuery.groupBy?.map((group) => ({
key: group?.name,
dataType: group?.fieldDataType,
type: group?.fieldContext,
id: `${group?.name}--${group?.fieldDataType}--${group?.fieldContext}`,
})),
orderBy: builderQuery.order?.map((order) => ({
columnName: order?.key?.name,
order: order?.direction,
})),
groupBy:
builderQuery.groupBy?.map((group) => ({
key: group?.name,
dataType: group?.fieldDataType,
type: group?.fieldContext,
id: `${group?.name}--${group?.fieldDataType}--${group?.fieldContext}`,
})) || [],
orderBy:
builderQuery.order?.map((order) => ({
columnName: order?.key?.name,
order: order?.direction,
})) || [],
} as unknown) as IBuilderQuery;
return result;

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==
@@ -4323,6 +4323,20 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/resizable@0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@signozhq/resizable/-/resizable-0.0.0.tgz#a517818b9f9bcdaeafc55ae134be86522bc90e9f"
integrity sha512-yAkJdMgTkh8kv42ZuabwTZguxalwYqIp4b44YdSrw6jRUSq9tscUBXVllNN79T71lPUtc5AV13uQ4Qm5AcfVbQ==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
react-resizable-panels "^3.0.5"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/sonner@0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@signozhq/sonner/-/sonner-0.1.0.tgz#1310cc530c60459608246550eb977a1ae27b6ce4"
@@ -15439,6 +15453,11 @@ react-remove-scroll@^2.6.3:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.3"
react-resizable-panels@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz#50a20645263eed02344de4a70d1319bbc0014bbd"
integrity sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==
react-resizable@3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz"
@@ -16103,6 +16122,13 @@ robust-predicates@^3.0.2:
resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771"
integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==
rrule@2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/rrule/-/rrule-2.8.1.tgz#e8341a9ce3e68ce5b8da4d502e893cd9f286805e"
integrity sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==
dependencies:
tslib "^2.4.0"
rtl-css-js@^1.14.0, rtl-css-js@^1.16.1:
version "1.16.1"
resolved "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz"

View File

@@ -207,7 +207,7 @@ var (
// TODO(srikanthccv): import metadata yaml from receivers and use generated files to check the metrics
podMetricNamesToCheck = []string{
GetDotMetrics("k8s_pod_cpu_usage"),
GetDotMetrics("k8s_pod_memory_usage"),
GetDotMetrics("k8s_pod_memory_working_set"),
GetDotMetrics("k8s_pod_cpu_request_utilization"),
GetDotMetrics("k8s_pod_memory_request_utilization"),
GetDotMetrics("k8s_pod_cpu_limit_utilization"),
@@ -218,7 +218,7 @@ var (
nodeMetricNamesToCheck = []string{
GetDotMetrics("k8s_node_cpu_usage"),
GetDotMetrics("k8s_node_allocatable_cpu"),
GetDotMetrics("k8s_node_memory_usage"),
GetDotMetrics("k8s_node_memory_working_set"),
GetDotMetrics("k8s_node_allocatable_memory"),
GetDotMetrics("k8s_node_condition_ready"),
}

View File

@@ -36,7 +36,7 @@ var (
metricNamesForNodes = map[string]string{
"cpu": GetDotMetrics("k8s_node_cpu_usage"),
"cpu_allocatable": GetDotMetrics("k8s_node_allocatable_cpu"),
"memory": GetDotMetrics("k8s_node_memory_usage"),
"memory": GetDotMetrics("k8s_node_memory_working_set"),
"memory_allocatable": GetDotMetrics("k8s_node_allocatable_memory"),
"node_condition": GetDotMetrics("k8s_node_condition_ready"),
}

View File

@@ -52,7 +52,7 @@ var (
"cpu": GetDotMetrics("k8s_pod_cpu_usage"),
"cpu_request": GetDotMetrics("k8s_pod_cpu_request_utilization"),
"cpu_limit": GetDotMetrics("k8s_pod_cpu_limit_utilization"),
"memory": GetDotMetrics("k8s_pod_memory_usage"),
"memory": GetDotMetrics("k8s_pod_memory_working_set"),
"memory_request": GetDotMetrics("k8s_pod_memory_request_utilization"),
"memory_limit": GetDotMetrics("k8s_pod_memory_limit_utilization"),
"restarts": GetDotMetrics("k8s_container_restarts"),

View File

@@ -7,7 +7,7 @@ var (
"cpu": GetDotMetrics("k8s_pod_cpu_usage"),
"cpu_request": GetDotMetrics("k8s_pod_cpu_request_utilization"),
"cpu_limit": GetDotMetrics("k8s_pod_cpu_limit_utilization"),
"memory": GetDotMetrics("k8s_pod_memory_usage"),
"memory": GetDotMetrics("k8s_pod_memory_working_set"),
"memory_request": GetDotMetrics("k8s_pod_memory_request_utilization"),
"memory_limit": GetDotMetrics("k8s_pod_memory_limit_utilization"),
"restarts": GetDotMetrics("k8s_container_restarts"),

View File

@@ -18,6 +18,8 @@ import (
var searchTroubleshootingGuideURL = "https://signoz.io/docs/userguide/search-troubleshooting/"
const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators-reference/#string-matching-operators"
// filterExpressionVisitor implements the FilterQueryVisitor interface
// to convert the parsed filter expressions into ClickHouse WHERE clause
type filterExpressionVisitor struct {
@@ -533,11 +535,13 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotLike
}
v.warnIfLikeWithoutWildcards("LIKE", value)
} else if ctx.ILIKE() != nil {
op = qbtypes.FilterOperatorILike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotILike
}
v.warnIfLikeWithoutWildcards("ILIKE", value)
} else if ctx.REGEXP() != nil {
op = qbtypes.FilterOperatorRegexp
if ctx.NOT() != nil {
@@ -571,6 +575,19 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
return "" // Should not happen with valid input
}
// warnIfLikeWithoutWildcards adds a guidance warning when LIKE/ILIKE is used without wildcards
func (v *filterExpressionVisitor) warnIfLikeWithoutWildcards(op string, value any) {
if hasLikeWildcards(value) {
return
}
msg := op + " operator used without wildcards (% or _). Consider using = operator for exact matches or add wildcards for pattern matching."
v.warnings = append(v.warnings, msg)
if v.mainWarnURL == "" {
v.mainWarnURL = stringMatchingOperatorDocURL
}
}
// VisitInClause handles IN expressions
func (v *filterExpressionVisitor) VisitInClause(ctx *grammar.InClauseContext) any {
if ctx.ValueList() != nil {
@@ -871,6 +888,15 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
return fieldKeysForName
}
// hasLikeWildcards checks if a value contains LIKE wildcards (% or _)
func hasLikeWildcards(value any) bool {
str, ok := value.(string)
if !ok {
return false
}
return strings.Contains(str, "%") || strings.Contains(str, "_")
}
func trimQuotes(txt string) string {
if len(txt) >= 2 {
if (txt[0] == '"' && txt[len(txt)-1] == '"') ||

View File

@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
@@ -60,7 +59,8 @@ func (migration *queryBuilderV5Migration) getTraceDuplicateKeys(ctx context.Cont
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query trace duplicate keys: %w", err)
migration.logger.WarnContext(ctx, "failed to query trace duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
@@ -68,7 +68,8 @@ func (migration *queryBuilderV5Migration) getTraceDuplicateKeys(ctx context.Cont
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("failed to scan trace duplicate key: %w", err)
migration.logger.WarnContext(ctx, "failed to scan trace duplicate key", "error", err)
continue
}
keys = append(keys, key)
}
@@ -89,7 +90,8 @@ func (migration *queryBuilderV5Migration) getLogDuplicateKeys(ctx context.Contex
rows, err := migration.telemetryStore.ClickhouseDB().Query(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to query log duplicate keys: %w", err)
migration.logger.WarnContext(ctx, "failed to query log duplicate keys", "error", err)
return nil, nil
}
defer rows.Close()
@@ -97,7 +99,8 @@ func (migration *queryBuilderV5Migration) getLogDuplicateKeys(ctx context.Contex
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("failed to scan log duplicate key: %w", err)
migration.logger.WarnContext(ctx, "failed to scan log duplicate key", "error", err)
continue
}
keys = append(keys, key)
}

View File

@@ -0,0 +1,82 @@
package telemetrylogs
import (
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/stretchr/testify/require"
)
// TestLikeAndILikeWithoutWildcards_Warns Tests that LIKE/ILIKE without wildcards add warnings and include docs URL
func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}
tests := []string{
"service.name LIKE 'demo-backend'",
"service.name ILIKE 'demo-backend'",
"service.name NOT LIKE 'demo-backend'",
"service.name NOT ILIKE 'demo-backend'",
}
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
require.NoError(t, err)
require.NotNil(t, clause)
require.NotEmpty(t, clause.Warnings, "expected warning for: %s", expr)
require.Contains(t, clause.Warnings[0], "operator used without wildcards")
require.Contains(t, clause.WarningsDocURL, "operators-reference/#string-matching-operators")
})
}
}
// TestLikeAndILikeWithWildcards_NoWarn Tests that LIKE/ILIKE with wildcards do not add warnings
func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonBodyPrefix: BodyJSONStringSearchPrefix,
JsonKeyToKey: GetBodyJSONKey,
}
tests := []string{
"service.name LIKE 'demo-%'",
"service.name LIKE '%demo'",
"service.name ILIKE '_demo'",
"service.name ILIKE '%demo%'",
}
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
require.NoError(t, err)
require.NotNil(t, clause)
require.Empty(t, clause.Warnings, "did not expect warnings for: %s", expr)
require.Empty(t, clause.WarningsDocURL)
})
}
}

View File

@@ -390,7 +390,11 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
innerSB.Select("trace_id", "duration_nano", sqlbuilder.Escape("resource_string_service$$name as `service.name`"), "name")
innerSB.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
innerSB.Where("parent_span_id = ''")
innerSB.Where("trace_id GLOBAL IN __toe")
// this only helps when there is a filter
if query.Filter != nil && query.Filter.Expression != "" {
innerSB.Where("trace_id GLOBAL IN __toe")
}
// Add time filter to inner query
innerSB.Where(

View File

@@ -552,6 +552,20 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query without any filter",
requestType: qbtypes.RequestTypeTrace,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()

View File

@@ -14,9 +14,10 @@ var (
traceExplorerRegex = regexp.MustCompile(`/traces-explorer(?:\?.*)?$`)
metricsExplorerRegex = regexp.MustCompile(`/metrics-explorer/explorer(?:\?.*)?$`)
meterRegex = regexp.MustCompile(`/meter(?:/.*)?(?:\?.*)?$`)
dashboardRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`)
dashboardIDRegex = regexp.MustCompile(`/dashboard/([a-f0-9\-]+)/`)
widgetIDRegex = regexp.MustCompile(`widgetId=([a-f0-9\-]+)`)
dashboardOpenRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+(?:\?.*)?$`)
dashboardEditRegex = regexp.MustCompile(`/dashboard/[a-zA-Z0-9\-]+/(new|edit)(?:\?.*)?$`)
dashboardIDRegex = regexp.MustCompile(`/dashboard/([a-zA-Z0-9\-]+)`)
widgetIDRegex = regexp.MustCompile(`widgetId=([a-zA-Z0-9\-]+)`)
ruleRegex = regexp.MustCompile(`/alerts/(new|edit)(?:\?.*)?$`)
ruleIDRegex = regexp.MustCompile(`ruleId=(\d+)`)
)
@@ -61,11 +62,12 @@ func CommentFromHTTPRequest(req *http.Request) map[string]string {
traceExplorerMatched := traceExplorerRegex.MatchString(referrer)
metricsExplorerMatched := metricsExplorerRegex.MatchString(referrer)
meterMatched := meterRegex.MatchString(referrer)
dashboardMatched := dashboardRegex.MatchString(referrer)
dashboardViewMatched := dashboardOpenRegex.MatchString(referrer)
dashboardEditMatched := dashboardEditRegex.MatchString(referrer)
ruleMatched := ruleRegex.MatchString(referrer)
switch {
case dashboardMatched:
case dashboardViewMatched, dashboardEditMatched:
comments["module_name"] = "dashboard"
case ruleMatched:
comments["module_name"] = "rule"
@@ -81,7 +83,7 @@ func CommentFromHTTPRequest(req *http.Request) map[string]string {
return comments
}
if dashboardMatched {
if dashboardViewMatched || dashboardEditMatched {
if matches := dashboardIDRegex.FindStringSubmatch(referrer); len(matches) > 1 {
comments["dashboard_id"] = matches[1]
}

View File

@@ -46,6 +46,11 @@ func TestCommentFromHTTPRequest(t *testing.T) {
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/dashboard/123/new"}}},
expected: map[string]string{"http_path": "/dashboard/123/new", "module_name": "dashboard", "dashboard_id": "123"},
},
{
name: "DashboardLandingPage",
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/dashboard/01982be0-d67e-7326-8955-2e99720a9f72?relativeTime=30m"}}},
expected: map[string]string{"http_path": "/dashboard/01982be0-d67e-7326-8955-2e99720a9f72", "module_name": "dashboard", "dashboard_id": "01982be0-d67e-7326-8955-2e99720a9f72"},
},
{
name: "Rule",
req: &http.Request{Header: http.Header{"Referer": {"https://signoz.io/alerts/new"}}},

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -423,13 +424,25 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
canDefaultZero := make(map[string]bool)
for _, q := range r.CompositeQuery.Queries {
if q.Type == QueryTypeBuilder {
if query, ok := q.Spec.(QueryBuilderQuery[TraceAggregation]); ok {
if len(query.Aggregations) == 1 && canDefaultZeroAgg(query.Aggregations[0].Expression) {
canDefaultZero[query.Name] = true
switch spec := q.Spec.(type) {
case QueryBuilderQuery[TraceAggregation]:
if len(spec.Aggregations) == 1 && canDefaultZeroAgg(spec.Aggregations[0].Expression) {
canDefaultZero[spec.Name] = true
}
} else if query, ok := q.Spec.(QueryBuilderQuery[LogAggregation]); ok {
if len(query.Aggregations) == 1 && canDefaultZeroAgg(query.Aggregations[0].Expression) {
canDefaultZero[query.Name] = true
case QueryBuilderQuery[LogAggregation]:
if len(spec.Aggregations) == 1 && canDefaultZeroAgg(spec.Aggregations[0].Expression) {
canDefaultZero[spec.Name] = true
}
case QueryBuilderQuery[MetricAggregation]:
if len(spec.Aggregations) == 1 {
timeAgg := spec.Aggregations[0].TimeAggregation
if timeAgg == metrictypes.TimeAggregationCount ||
timeAgg == metrictypes.TimeAggregationCountDistinct ||
timeAgg == metrictypes.TimeAggregationRate ||
timeAgg == metrictypes.TimeAggregationIncrease {
canDefaultZero[spec.Name] = true
}
}
}
}

View File

@@ -1683,6 +1683,86 @@ func TestQueryRangeRequest_GetQueriesSupportingZeroDefault(t *testing.T) {
"A": true,
},
},
{
name: "test metrics",
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = demo",
},
Aggregations: []MetricAggregation{
{
MetricName: "calls",
TimeAggregation: metrictypes.TimeAggregationRate,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = demo",
},
Aggregations: []MetricAggregation{
{
MetricName: "memory.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = demo",
},
Aggregations: []MetricAggregation{
{
MetricName: "calls",
TimeAggregation: metrictypes.TimeAggregationIncrease,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
},
},
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalTraces,
Filter: &Filter{
Expression: "service.name = demo",
},
Aggregations: []MetricAggregation{
{
MetricName: "calls",
TimeAggregation: metrictypes.TimeAggregationCount,
SpaceAggregation: metrictypes.SpaceAggregationSum,
},
},
},
},
},
},
want: map[string]bool{
"A": true,
"C": true,
"D": true,
},
},
{
name: "test min on logs - doesn't support zeroDefault",
CompositeQuery: CompositeQuery{