Compare commits

...

18 Commits

Author SHA1 Message Date
Piyush Singariya
029cd38e21 fix: test + rename files 2026-02-18 17:11:47 +05:30
Piyush Singariya
421f3bdd9e fix: go test 2026-02-18 17:01:20 +05:30
Piyush Singariya
53a0b532bb fix: go ci errors 2026-02-18 16:25:57 +05:30
Piyush Singariya
03a13678d0 Merge branch 'main' into lp-filter-v5 2026-02-18 16:14:33 +05:30
Piyush Singariya
14d4751c96 fix: tests and migration ran successfully 2026-02-18 16:10:20 +05:30
primus-bot[bot]
04643264ff chore(release): bump to v0.112.0 (#10340)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-02-18 15:50:23 +05:30
Piyush Singariya
b50d8136a5 Merge branch 'main' into lp-filter-v5 2026-02-18 15:34:37 +05:30
Piyush Singariya
4103fe4d2f fix: added test and some fixes 2026-02-18 14:29:52 +05:30
Ishan
3aa0d8a7fd feat: Improve logs browsing when the side-drawer is open (#10250)
* feat: logs keyboard handle feature

* feat: raw logs code optimised

* feat: listlogs and table view optimised

* feat: added chevron arrows in log details

* feat: added bg table scrolling

* feat: entity logs bg click bug

* feat: pr comment fixes

* feat: pr optimised

* feat: removed unwanted code

* feat: removed unwanted code
2026-02-18 14:12:00 +05:30
Piyush Singariya
ccc102dc7b fix: singular key fix 2026-02-10 14:56:40 +05:30
Piyush Singariya
2cd0ab8a28 fix: singular key in comparison condition 2026-02-10 14:53:51 +05:30
Piyush Singariya
b101ffeae1 fix: no attributes. in filter 2026-02-10 14:28:52 +05:30
Piyush Singariya
d7f543ad4a fix: working on migration 2026-02-10 14:22:51 +05:30
Piyush Singariya
f81c64f348 feat: address negate 2026-02-09 20:32:40 +05:30
Piyush Singariya
40956116dc fix: qbtoexpr migrated to v5 2026-02-09 20:24:32 +05:30
Piyush Singariya
b49a95b7b3 Merge branch 'main' into lp-filter-v5 2026-02-09 13:06:54 +05:30
Piyush Singariya
ae19bb2be2 fix: filter expression 2026-02-05 18:10:45 +05:30
Piyush Singariya
749f52ff9d chore: moving from v3 to v5 2026-02-05 17:48:57 +05:30
44 changed files with 2729 additions and 1109 deletions

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.111.0
image: signoz/signoz:v0.112.0
command:
- --config=/root/config/prometheus.yml
ports:

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.111.0
image: signoz/signoz:v0.112.0
command:
- --config=/root/config/prometheus.yml
ports:

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.111.0}
image: signoz/signoz:${VERSION:-v0.112.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

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.111.0}
image: signoz/signoz:${VERSION:-v0.112.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -1,8 +1,9 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -10,6 +11,8 @@ import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -28,6 +31,15 @@ interface Props {
}
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
@@ -72,29 +84,40 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
setIsPaginating(false);
}, [data, setIsPaginating]);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
/>
),
[],
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const renderFooter = useCallback(
@@ -118,6 +141,7 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
@@ -139,7 +163,24 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div className="host-metrics-logs-list-container">{renderContent}</div>
<div
className="host-metrics-logs-list-container"
data-log-detail-ignore="true"
>
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);

View File

@@ -13,6 +13,9 @@ export type LogDetailProps = {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
logs?: ILog[];
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (logId: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -15,6 +15,8 @@
}
.log-detail-drawer__title-right {
display: flex;
align-items: center;
.ant-btn {
display: flex;
align-items: center;
@@ -66,6 +68,10 @@
margin-bottom: 16px;
}
.log-detail-drawer__content {
height: 100%;
}
.log-detail-drawer__log {
width: 100%;
display: flex;
@@ -183,9 +189,115 @@
.ant-drawer-close {
padding: 0px;
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-200);
background: var(--bg-ink-400);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-200);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
.log-arrows {
display: flex;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 2px 6px;
align-items: center;
margin-left: 8px;
}
.log-arrow-btn {
padding: 0;
min-width: 28px;
height: 28px;
border-radius: 4px;
background: var(--bg-ink-400);
color: var(--text-vanilla-400);
border: 1px solid var(--bg-ink-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-ink-400);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
.log-arrow-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
background: var(--bg-ink-500);
color: var(--text-vanilla-200);
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
}
}
.lightMode {
.log-arrows {
background: var(--bg-vanilla-100);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn {
background: var(--bg-vanilla-100);
color: var(--text-ink-400);
border: 1px solid var(--bg-vanilla-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-vanilla-100);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn[disabled] {
background: var(--bg-vanilla-100);
color: var(--text-ink-200);
}
.ant-drawer-header {
border-bottom: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100);
@@ -252,4 +364,33 @@
color: var(--text-ink-300);
}
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-700);
background: var(--bg-vanilla-100);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-700);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
@@ -32,8 +32,12 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
BarChart2,
Braces,
ChevronDown,
ChevronUp,
Compass,
Copy,
Filter,
@@ -60,6 +64,9 @@ function LogDetailInner({
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
onScrollToLog,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -74,6 +81,78 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
// Don't close if clicking on explicitly ignored regions
if (target.closest('[data-log-detail-ignore="true"]')) {
return;
}
// Close the drawer for any other outside click
onClose?.(e as any);
};
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Keyboard navigation - handle up/down arrow keys
// Only listen when in OVERVIEW tab
useEffect(() => {
if (
!logs ||
!onNavigateLog ||
logs.length === 0 ||
selectedView !== VIEW_TYPES.OVERVIEW
) {
return;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const currentIndex = logs.findIndex((l) => l.id === log.id);
if (currentIndex === -1) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
// Navigate to previous log
if (currentIndex > 0) {
const prevLog = logs[currentIndex - 1];
onNavigateLog(prevLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(prevLog.id);
}
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
// Navigate to next log
if (currentIndex < logs.length - 1) {
const nextLog = logs[currentIndex + 1];
onNavigateLog(nextLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(nextLog.id);
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [log.id, logs, onNavigateLog, onScrollToLog, selectedView]);
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) {
return null;
@@ -227,32 +306,87 @@ function LogDetailInner({
);
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
!logs || !onNavigateLog || logs.length === 0 || currentLogIndex <= 0;
const isNextDisabled =
!logs ||
!onNavigateLog ||
logs.length === 0 ||
currentLogIndex === logs.length - 1;
type HandleNavigateLogParams = {
direction: 'next' | 'previous';
};
const handleNavigateLog = ({ direction }: HandleNavigateLogParams): void => {
if (!logs || !onNavigateLog || currentLogIndex === -1) {
return;
}
if (direction === 'previous' && !isPrevDisabled) {
const prevLog = logs[currentLogIndex - 1];
onNavigateLog(prevLog);
onScrollToLog?.(prevLog.id);
} else if (direction === 'next' && !isNextDisabled) {
const nextLog = logs[currentLogIndex + 1];
onNavigateLog(nextLog);
onScrollToLog?.(nextLog.id);
}
};
return (
<Drawer
width="60%"
maskStyle={{ background: 'none' }}
mask={false}
maskClosable={false}
title={
<div className="log-detail-drawer__title">
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
{showOpenInExplorerBtn && (
<div className="log-detail-drawer__title-right">
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
Open in Explorer
</Button>
<Button
icon={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
icon={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={(): void => handleNavigateLog({ direction: 'next' })}
/>
</Tooltip>
</div>
)}
{showOpenInExplorerBtn && (
<div>
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
</div>
}
placement="right"
// closable
onClose={drawerCloseHandler}
open={log !== null}
style={{
@@ -263,138 +397,164 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
>
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
>
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label="Show Filters"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</Tooltip>
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
)}
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -2,13 +2,11 @@ import { memo, useCallback, useMemo } from 'react';
import { blue } from '@ant-design/colors';
import { Typography } from 'antd';
import cx from 'classnames';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
@@ -104,12 +102,17 @@ function LogSelectedField({
type ListLogViewProps = {
logData: ILog;
selectedFields: IField[];
onSetActiveLog: (log: ILog) => void;
onSetActiveLog: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onAddToQuery: AddToQueryHOCProps['onAddToQuery'];
activeLog?: ILog | null;
linesPerRow: number;
fontSize: FontSize;
handleChangeSelectedView?: ChangeViewFunctionType;
isActiveLog?: boolean;
onClearActiveLog?: () => void;
};
function ListLogView({
@@ -120,7 +123,8 @@ function ListLogView({
activeLog,
linesPerRow,
fontSize,
handleChangeSelectedView,
isActiveLog,
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
@@ -129,35 +133,24 @@ function ListLogView({
);
const isReadOnlyLog = !isLogsExplorerPage;
const {
activeLog: activeContextLog,
onAddToQuery: handleAddToQuery,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
} = useActiveLog();
const isDarkMode = useIsDarkMode();
const handlerClearActiveContextLog = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
handleClearActiveContextLog();
},
[handleClearActiveContextLog],
);
const handleDetailedView = useCallback(() => {
if (isActiveLog) {
onClearActiveLog?.();
return;
}
onSetActiveLog(logData);
}, [logData, onSetActiveLog]);
}, [logData, onSetActiveLog, isActiveLog, onClearActiveLog]);
const handleShowContext = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
handleSetActiveContextLog(logData);
onSetActiveLog(logData, VIEW_TYPES.CONTEXT);
},
[logData, handleSetActiveContextLog],
[logData, onSetActiveLog],
);
const updatedSelecedFields = useMemo(
@@ -186,11 +179,7 @@ function ListLogView({
return (
<>
<Container
$isActiveLog={
isHighlighted ||
activeLog?.id === logData.id ||
activeContextLog?.id === logData.id
}
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
$isDarkMode={isDarkMode}
$logType={logType}
onClick={handleDetailedView}
@@ -251,15 +240,6 @@ function ListLogView({
/>
)}
</Container>
{activeContextLog && (
<LogDetail
log={activeContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
onClose={handlerClearActiveContextLog}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</>
);
}

View File

@@ -1,19 +1,15 @@
import {
KeyboardEvent,
memo,
MouseEvent,
MouseEventHandler,
useCallback,
useMemo,
useState,
} from 'react';
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 { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -39,7 +35,8 @@ function RawLogView({
selectedFields = [],
fontSize,
onLogClick,
handleChangeSelectedView,
onSetActiveLog,
onClearActiveLog,
}: RawLogViewProps): JSX.Element {
const {
isHighlighted: isUrlHighlighted,
@@ -48,15 +45,6 @@ function RawLogView({
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<VIEWS | undefined>();
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -134,34 +122,24 @@ function RawLogView({
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
onLogClick(data, event);
} else {
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
return;
}
if (isActiveLog) {
onClearActiveLog?.();
return;
}
},
[isReadOnly, data, onSetActiveLog, onLogClick],
);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
(
event: MouseEvent<Element, globalThis.MouseEvent> | KeyboardEvent<Element>,
) => {
event.preventDefault();
event.stopPropagation();
onClearActiveLog();
setSelectedTab(undefined);
onSetActiveLog?.(data);
},
[onClearActiveLog],
[isReadOnly, onLogClick, isActiveLog, onSetActiveLog, data, onClearActiveLog],
);
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
// handleSetActiveContextLog(data);
setSelectedTab(VIEW_TYPES.CONTEXT);
onSetActiveLog(data);
onSetActiveLog?.(data, VIEW_TYPES.CONTEXT);
},
[data, onSetActiveLog],
);
@@ -181,7 +159,7 @@ function RawLogView({
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isUrlHighlighted}
$isActiveLog={activeLog?.id === data.id || isActiveLog}
$isActiveLog={isActiveLog}
$isCustomHighlighted={isHighlighted}
$logType={logType}
fontSize={fontSize}
@@ -218,17 +196,6 @@ function RawLogView({
onLogCopy={onLogCopy}
/>
)}
{selectedTab && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</RawLogViewContainer>
);
}

View File

@@ -45,9 +45,6 @@ export const RawLogViewContainer = styled(Row)<{
: `margin: 2px 0;`}
}
${({ $isActiveLog, $logType }): string =>
getActiveLogBackground($isActiveLog, true, $logType)}
${({ $isReadOnly, $isActiveLog, $isDarkMode, $logType }): string =>
$isActiveLog
? getActiveLogBackground($isActiveLog, $isDarkMode, $logType)

View File

@@ -1,4 +1,5 @@
import { MouseEvent } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
@@ -16,6 +17,11 @@ export interface RawLogViewProps {
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
handleChangeSelectedView?: ChangeViewFunctionType;
onSetActiveLog?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onClearActiveLog?: () => void;
}
export interface RawLogContentProps {

View File

@@ -1,8 +1,9 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -11,6 +12,8 @@ import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -40,6 +43,15 @@ function EntityLogs({
category,
queryKeyFilters,
}: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getEntityEventsOrLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
@@ -62,29 +74,40 @@ function EntityLogs({
basePayload,
});
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
/>
),
[],
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const { data, isLoading, isFetching, isError } = useQuery({
@@ -131,6 +154,7 @@ function EntityLogs({
<Virtuoso
className="entity-logs-virtuoso"
key="entity-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
@@ -154,7 +178,21 @@ function EntityLogs({
)}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div className="entity-logs-list-container">{renderContent}</div>
<div className="entity-logs-list-container" data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);

View File

@@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -14,8 +13,9 @@ import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -38,10 +38,11 @@ function LiveLogsList({
const {
activeLog,
onClearActiveLog,
onAddToQuery,
onSetActiveLog,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
// get only data from the logs object
const formattedLogs: ILog[] = useMemo(
@@ -65,42 +66,56 @@ function LiveLogsList({
...options.selectColumns,
]);
const handleScrollToLog = useScrollToLog({
logs: formattedLogs,
virtuosoRef: ref,
});
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<RawLogView
data={log}
isActiveLog={activeLog?.id === log.id}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selectedFields}
linesPerRow={options.maxLines}
onAddToQuery={onAddToQuery}
onSetActiveLog={onSetActiveLog}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<ListLogView
logData={log}
isActiveLog={activeLog?.id === log.id}
selectedFields={selectedFields}
linesPerRow={options.maxLines}
onAddToQuery={onAddToQuery}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
);
},
[
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
options.format,
options.maxLines,
options.fontSize,
activeLog?.id,
selectedFields,
onAddToQuery,
handleSetActiveLog,
handleCloseLogDetail,
handleChangeSelectedView,
],
);
@@ -156,6 +171,10 @@ function LiveLogsList({
activeLogIndex,
}}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
@@ -173,14 +192,17 @@ function LiveLogsList({
</InfinityWrapperStyled>
)}
{activeLog && (
{activeLog && selectedTab && (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
selectedTab={selectedTab}
log={activeLog}
onClose={onClearActiveLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}
onScrollToLog={handleScrollToLog}
/>
)}
</div>

View File

@@ -395,7 +395,7 @@ export default function TableViewActions(
onOpenChange={setIsOpen}
arrow={false}
content={
<div>
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"
@@ -481,7 +481,7 @@ export default function TableViewActions(
onOpenChange={setIsOpen}
arrow={false}
content={
<div>
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"

View File

@@ -7,6 +7,7 @@ import {
useMemo,
} from 'react';
import { ColumnsType } from 'antd/es/table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { FontSize } from 'container/OptionsMenu/types';
@@ -22,22 +23,27 @@ interface TableRowProps {
tableColumns: ColumnsType<Record<string, unknown>>;
index: number;
log: Record<string, unknown>;
handleSetActiveContextLog: (log: ILog) => void;
onShowLogDetails: (log: ILog) => void;
onShowLogDetails?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
logs: ILog[];
hasActions: boolean;
fontSize: FontSize;
isActiveLog?: boolean;
onClearActiveLog?: () => void;
}
export default function TableRow({
tableColumns,
index,
log,
handleSetActiveContextLog,
onShowLogDetails,
logs,
hasActions,
fontSize,
isActiveLog,
onClearActiveLog,
}: TableRowProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -52,21 +58,31 @@ export default function TableRow({
(event) => {
event.preventDefault();
event.stopPropagation();
if (!handleSetActiveContextLog || !currentLog) {
if (!currentLog) {
return;
}
handleSetActiveContextLog(currentLog);
onShowLogDetails?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, handleSetActiveContextLog],
[currentLog, onShowLogDetails],
);
const handleShowLogDetails = useCallback(() => {
if (!onShowLogDetails || !currentLog) {
if (!currentLog) {
return;
}
onShowLogDetails(currentLog);
}, [currentLog, onShowLogDetails]);
// If this log is already active, close the detail drawer
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
// Otherwise, open the detail drawer for this log
if (onShowLogDetails) {
onShowLogDetails(currentLog);
}
}, [currentLog, onShowLogDetails, isActiveLog, onClearActiveLog]);
const hasSingleColumn =
tableColumns.filter((column) => column.key !== 'state-indicator').length ===

View File

@@ -4,7 +4,6 @@ import {
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { useTableView } from 'components/Logs/TableView/useTableView';
@@ -58,26 +57,40 @@ const CustomTableRow: TableComponents<ILog>['TableRow'] = ({
const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function InfinityTableView(
{ isLoading, tableViewProps, infitiyTableProps, handleChangeSelectedView },
ref,
): JSX.Element | null {
const {
activeLog: activeContextLog,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
onAddToQuery: handleAddToQuery,
} = useActiveLog();
const {
activeLog,
{
isLoading,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
activeLog,
},
ref,
): JSX.Element | null {
const { activeLog: activeContextLog } = useActiveLog();
const onSetActiveLogExpand = useCallback(
(log: ILog) => {
onSetActiveLog?.(log);
},
[onSetActiveLog],
);
const onSetActiveLogContext = useCallback(
(log: ILog) => {
onSetActiveLog?.(log, VIEW_TYPES.CONTEXT);
},
[onSetActiveLog],
);
const onCloseActiveLog = useCallback(() => {
onClearActiveLog?.();
}, [onClearActiveLog]);
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: handleSetActiveContextLog,
onClickExpand: onSetActiveLogExpand,
onOpenLogsContext: onSetActiveLogContext,
});
const { draggedColumns, onDragColumns } = useDragColumns<
@@ -98,27 +111,32 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
);
const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => (
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
handleSetActiveContextLog={handleSetActiveContextLog}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
/>
),
(index: number, log: Record<string, unknown>): JSX.Element => {
return (
<div key={log.id as string}>
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
isActiveLog={activeLog?.id === log.id}
onClearActiveLog={onCloseActiveLog}
/>
</div>
);
},
[
handleSetActiveContextLog,
tableColumns,
tableViewProps.fontSize,
tableViewProps.logs,
onSetActiveLog,
tableViewProps.logs,
tableViewProps.fontSize,
activeLog?.id,
onCloseActiveLog,
],
);
const tableHeader = useCallback(
() => (
<tr>
@@ -179,24 +197,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
? { endReached: infitiyTableProps.onEndReached }
: {})}
/>
{activeContextLog && (
<LogDetail
log={activeContextLog}
onClose={handleClearActiveContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
);
},

View File

@@ -1,5 +1,7 @@
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { UseTableViewProps } from 'components/Logs/TableView/types';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ILog } from 'types/api/logs/log';
export type InfinityTableProps = {
isLoading?: boolean;
@@ -8,4 +10,11 @@ export type InfinityTableProps = {
onEndReached: (index: number) => void;
};
handleChangeSelectedView?: ChangeViewFunctionType;
logs?: ILog[];
onSetActiveLog?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onClearActiveLog?: () => void;
activeLog?: ILog | null;
};

View File

@@ -4,7 +4,6 @@ import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
// components
import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
@@ -16,8 +15,9 @@ import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import APIError from 'types/api/error';
// interfaces
@@ -55,10 +55,11 @@ function LogsExplorerList({
const {
activeLog,
onClearActiveLog,
onAddToQuery,
onSetActiveLog,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -82,6 +83,12 @@ function LogsExplorerList({
() => convertKeysToColumnFields(options.selectColumns),
[options],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef: ref,
});
useEffect(() => {
if (!isLoading && !isFetching && !isError && logs.length !== 0) {
logEvent('Logs Explorer: Data present', {
@@ -94,40 +101,48 @@ function LogsExplorerList({
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<RawLogView
data={log}
isActiveLog={activeLog?.id === log.id}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selectedFields}
onAddToQuery={onAddToQuery}
onSetActiveLog={onSetActiveLog}
activeLog={activeLog}
fontSize={options.fontSize}
linesPerRow={options.maxLines}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<ListLogView
logData={log}
isActiveLog={activeLog?.id === log.id}
selectedFields={selectedFields}
onAddToQuery={onAddToQuery}
onSetActiveLog={handleSetActiveLog}
activeLog={activeLog}
fontSize={options.fontSize}
linesPerRow={options.maxLines}
handleChangeSelectedView={handleChangeSelectedView}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
},
[
activeLog,
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
options.format,
options.fontSize,
options.maxLines,
activeLog,
selectedFields,
onAddToQuery,
handleSetActiveLog,
handleChangeSelectedView,
handleCloseLogDetail,
],
);
@@ -153,6 +168,10 @@ function LogsExplorerList({
}}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
/>
);
}
@@ -199,6 +218,9 @@ function LogsExplorerList({
getItemContent,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
]);
const isTraceToLogsNavigation = useMemo(() => {
@@ -278,14 +300,19 @@ function LogsExplorerList({
{renderContent}
</InfinityWrapperStyled>
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
{selectedTab && activeLog && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}
onScrollToLog={handleScrollToLog}
/>
)}
</>
)}
</div>

View File

@@ -466,7 +466,10 @@ function LogsExplorerViewsContainer({
</div>
)}
<div className="logs-explorer-views-type-content">
<div
className="logs-explorer-views-type-content"
data-log-detail-ignore="true"
>
{showLiveLogs && (
<LiveLogs handleChangeSelectedView={handleChangeSelectedView} />
)}

View File

@@ -8,7 +8,6 @@ import {
} from 'react';
import { UseQueryResult } from 'react-query';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { ResizeTable } from 'components/ResizeTable';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -16,7 +15,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import Controls from 'container/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import { useLogsData } from 'hooks/useLogsData';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { FlatLogData } from 'lib/logs/flatLogData';
@@ -83,24 +82,24 @@ function LogsPanelComponent({
() => logs.map((log) => FlatLogData(log) as RowData),
[logs],
);
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const handleRow = useCallback(
(record: RowData): HTMLAttributes<RowData> => ({
onClick: (): void => {
const log = logs.find((item) => item.id === record.id);
if (log) {
onSetActiveLog(log);
handleSetActiveLog(log);
}
},
}),
[logs, onSetActiveLog],
[handleSetActiveLog, logs],
);
const handleRequestData = (newOffset: number): void => {
@@ -132,7 +131,7 @@ function LogsPanelComponent({
return (
<>
<div className="logs-table">
<div className="logs-table" data-log-detail-ignore="true">
<div className="resize-table">
<OverlayScrollbar>
<ResizeTable
@@ -166,15 +165,19 @@ function LogsPanelComponent({
</div>
)}
</div>
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
/>
{selectedTab && activeLog && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
logs={logs}
onNavigateLog={handleSetActiveLog}
/>
)}
</>
);
}

View File

@@ -0,0 +1,59 @@
import { useCallback, useState } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import type { UseActiveLog } from 'hooks/logs/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { ILog } from 'types/api/logs/log';
type SelectedTab = typeof VIEW_TYPES[keyof typeof VIEW_TYPES] | undefined;
type UseLogDetailHandlersParams = {
defaultTab?: SelectedTab;
};
type UseLogDetailHandlersResult = {
activeLog: UseActiveLog['activeLog'];
onAddToQuery: UseActiveLog['onAddToQuery'];
selectedTab: SelectedTab;
handleSetActiveLog: (log: ILog, selectedTab?: SelectedTab) => void;
handleCloseLogDetail: () => void;
};
function useLogDetailHandlers({
defaultTab = VIEW_TYPES.OVERVIEW,
}: UseLogDetailHandlersParams = {}): UseLogDetailHandlersResult {
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<SelectedTab>(defaultTab);
const handleSetActiveLog = useCallback(
(log: ILog, nextTab: SelectedTab = defaultTab): void => {
if (activeLog?.id === log.id) {
onClearActiveLog();
setSelectedTab(undefined);
return;
}
onSetActiveLog(log);
setSelectedTab(nextTab ?? defaultTab);
},
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
);
const handleCloseLogDetail = useCallback((): void => {
onClearActiveLog();
setSelectedTab(undefined);
}, [onClearActiveLog]);
return {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
};
}
export default useLogDetailHandlers;

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
};
function useScrollToLog({
logs,
virtuosoRef,
}: UseScrollToLogParams): (logId: string) => void {
return useCallback(
(logId: string): void => {
const logIndex = logs.findIndex(({ id }) => id === logId);
if (logIndex !== -1 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: logIndex,
align: 'center',
behavior: 'smooth',
});
}
},
[logs, virtuosoRef],
);
}
export default useScrollToLog;

View File

@@ -567,6 +567,15 @@ body {
border: 1px solid var(--bg-vanilla-300);
}
.ant-tooltip {
--antd-arrow-background-color: var(--bg-vanilla-100);
.ant-tooltip-inner {
background-color: var(--bg-vanilla-100);
color: var(---bg-ink-500);
}
}
.ant-dropdown-menu {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -9,10 +9,9 @@ export const getDefaultLogBackground = (
if (isReadOnly) {
return '';
}
// TODO handle the light mode here
return `&:hover {
background-color: ${
isDarkMode ? 'rgba(171, 189, 255, 0.04)' : 'var(--bg-vanilla-200)'
isDarkMode ? 'rgba(171, 189, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)'
};
}`;
};
@@ -28,22 +27,38 @@ export const getActiveLogBackground = (
if (isDarkMode) {
switch (logType) {
case LogType.INFO:
return `background-color: ${Color.BG_ROBIN_500}10 !important;`;
return `background-color: ${Color.BG_ROBIN_500}40 !important;`;
case LogType.WARN:
return `background-color: ${Color.BG_AMBER_500}10 !important;`;
return `background-color: ${Color.BG_AMBER_500}40 !important;`;
case LogType.ERROR:
return `background-color: ${Color.BG_CHERRY_500}10 !important;`;
return `background-color: ${Color.BG_CHERRY_500}40 !important;`;
case LogType.TRACE:
return `background-color: ${Color.BG_FOREST_400}10 !important;`;
return `background-color: ${Color.BG_FOREST_400}40 !important;`;
case LogType.DEBUG:
return `background-color: ${Color.BG_AQUA_500}10 !important;`;
return `background-color: ${Color.BG_AQUA_500}40 !important;`;
case LogType.FATAL:
return `background-color: ${Color.BG_SAKURA_500}10 !important;`;
return `background-color: ${Color.BG_SAKURA_500}40 !important;`;
default:
return `background-color: ${Color.BG_SLATE_200} !important;`;
return `background-color: ${Color.BG_ROBIN_500}40 !important;`;
}
}
return `background-color: ${Color.BG_VANILLA_400}!important; color: ${Color.TEXT_SLATE_400} !important;`;
// Light mode - use lighter background colors
switch (logType) {
case LogType.INFO:
return `background-color: ${Color.BG_ROBIN_100} !important;`;
case LogType.WARN:
return `background-color: ${Color.BG_AMBER_100} !important;`;
case LogType.ERROR:
return `background-color: ${Color.BG_CHERRY_100} !important;`;
case LogType.TRACE:
return `background-color: ${Color.BG_FOREST_200} !important;`;
case LogType.DEBUG:
return `background-color: ${Color.BG_AQUA_100} !important;`;
case LogType.FATAL:
return `background-color: ${Color.BG_SAKURA_100} !important;`;
default:
return `background-color: ${Color.BG_VANILLA_300} !important;`;
}
};
export const getHightLightedLogBackground = (

View File

@@ -7,9 +7,9 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
. "github.com/smartystreets/goconvey/convey"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -96,10 +96,10 @@ var buildProcessorTestData = []struct {
func TestBuildLogParsingProcessors(t *testing.T) {
for _, test := range buildProcessorTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := updateProcessorConfigsInCollectorConf(test.agentConf, test.pipelineProcessor)
So(err, ShouldBeNil)
So(test.agentConf, ShouldResemble, test.outputConf)
assert.NoError(t, err)
assert.Equal(t, test.outputConf, test.agentConf)
})
}
@@ -202,11 +202,11 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
v, err := buildCollectorPipelineProcessorsList(test.currentPipeline, test.logsPipeline)
So(err, ShouldBeNil)
assert.NoError(t, err)
fmt.Println(test.Name, "\n", test.currentPipeline, "\n", v, "\n", test.expectedPipeline)
So(v, ShouldResemble, test.expectedPipeline)
assert.Equal(t, test.expectedPipeline, v)
})
}
}
@@ -239,19 +239,8 @@ func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *test
Alias: alias,
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -310,19 +299,8 @@ func TestPipelineRouterWorksEvenIfFirstOpIsDisabled(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -383,19 +361,8 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
Alias: "test|pipeline",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -11,13 +11,13 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
@@ -140,15 +140,8 @@ func (ic *LogParsingPipelineController) getDefaultPipelines() ([]pipelinetypes.G
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
},
Filter: &qbtypes.Filter{
Expression: "body EXISTS",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -9,14 +9,14 @@ import (
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -203,10 +203,10 @@ var prepareProcessorTestData = []struct {
func TestPreparePipelineProcessor(t *testing.T) {
for _, test := range prepareProcessorTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
res, err := getOperators(test.Operators)
So(err, ShouldBeNil)
So(res, ShouldResemble, test.Output)
assert.NoError(t, err)
assert.Equal(t, test.Output, res)
})
}
}
@@ -214,19 +214,8 @@ func TestPreparePipelineProcessor(t *testing.T) {
func TestNoCollectorErrorsFromProcessorsForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
@@ -470,19 +459,8 @@ func TestResourceFiltersWork(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: "=",
Value: "nginx",
},
},
Filter: &qbtypes.Filter{
Expression: "resource.service = 'nginx'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -524,11 +502,11 @@ func TestResourceFiltersWork(t *testing.T) {
func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t *testing.T) {
require := require.New(t)
for _, operator := range []v3.FilterOperator{
v3.FilterOperatorContains,
v3.FilterOperatorNotContains,
v3.FilterOperatorRegex,
v3.FilterOperatorNotRegex,
for _, operator := range []string{
"CONTAINS",
"NOT CONTAINS",
"REGEXP",
"NOT REGEXP",
} {
testPipeline := pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
@@ -540,19 +518,8 @@ func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: operator,
Value: "nginx",
},
},
Filter: &qbtypes.Filter{
Expression: fmt.Sprintf("resource.service %s 'nginx'", operator),
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -601,19 +568,8 @@ func TestAttributePathsContainingDollarDoNotBreakCollector(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "$test",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "test",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.$test = 'test'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -664,19 +620,8 @@ func TestMembershipOpInProcessorFieldExpressions(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.http.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -755,7 +700,7 @@ func TestMembershipOpInProcessorFieldExpressions(t *testing.T) {
}
func TestContainsFilterIsCaseInsensitive(t *testing.T) {
// The contains and ncontains query builder filters are case insensitive when querying logs.
// The CONTAINS and NOT CONTAINS query builder filters are case insensitive when querying logs.
// Pipeline filter should also behave in the same way.
require := require.New(t)
@@ -773,18 +718,8 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "contains",
Value: "log",
}},
Filter: &qbtypes.Filter{
Expression: "body CONTAINS 'log'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -806,18 +741,8 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "ncontains",
Value: "ecom",
}},
Filter: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'ecom'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -7,8 +7,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/require"
@@ -25,19 +25,8 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -58,19 +47,8 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -159,19 +137,8 @@ func TestGrokParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/stretchr/testify/require"
)
@@ -30,19 +30,8 @@ func TestRegexProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -97,19 +86,8 @@ func TestGrokProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -164,19 +142,8 @@ func TestJSONProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -230,20 +197,9 @@ func TestTraceParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
}
@@ -339,19 +295,8 @@ func TestAddProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -404,19 +349,8 @@ func TestRemoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -469,19 +403,8 @@ func TestCopyProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -535,19 +458,8 @@ func TestMoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -7,8 +7,8 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -23,19 +23,8 @@ func TestSeverityParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -141,19 +130,8 @@ func TestSeverityParsingProcessor(t *testing.T) {
func TestNoCollectorErrorsFromSeverityParserForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{

View File

@@ -8,8 +8,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -24,19 +24,8 @@ func TestTimestampParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -2,11 +2,16 @@ package queryBuilderToExpr
import (
"fmt"
"maps"
"reflect"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
expr "github.com/antonmedv/expr"
"go.uber.org/zap"
)
@@ -15,119 +20,233 @@ var (
CodeExprCompilationFailed = errors.MustNewCode("expr_compilation_failed")
)
var logOperatorsToExpr = map[v3.FilterOperator]string{
v3.FilterOperatorEqual: "==",
v3.FilterOperatorNotEqual: "!=",
v3.FilterOperatorLessThan: "<",
v3.FilterOperatorLessThanOrEq: "<=",
v3.FilterOperatorGreaterThan: ">",
v3.FilterOperatorGreaterThanOrEq: ">=",
v3.FilterOperatorContains: "contains",
v3.FilterOperatorNotContains: "not contains",
v3.FilterOperatorRegex: "matches",
v3.FilterOperatorNotRegex: "not matches",
v3.FilterOperatorIn: "in",
v3.FilterOperatorNotIn: "not in",
v3.FilterOperatorExists: "in",
v3.FilterOperatorNotExists: "not in",
// we dont support like and nlike as of now.
var logOperatorsToExpr = map[qbtypes.FilterOperator]string{
qbtypes.FilterOperatorEqual: "==",
qbtypes.FilterOperatorNotEqual: "!=",
qbtypes.FilterOperatorLessThan: "<",
qbtypes.FilterOperatorLessThanOrEq: "<=",
qbtypes.FilterOperatorGreaterThan: ">",
qbtypes.FilterOperatorGreaterThanOrEq: ">=",
qbtypes.FilterOperatorContains: "contains",
qbtypes.FilterOperatorNotContains: "not contains",
qbtypes.FilterOperatorRegexp: "matches",
qbtypes.FilterOperatorNotRegexp: "not matches",
qbtypes.FilterOperatorIn: "in",
qbtypes.FilterOperatorNotIn: "not in",
qbtypes.FilterOperatorExists: "in",
qbtypes.FilterOperatorNotExists: "not in",
// nlike and like are not supported yet
}
func getName(v v3.AttributeKey) string {
if v.Type == v3.AttributeKeyTypeTag {
return fmt.Sprintf(`attributes["%s"]`, v.Key)
} else if v.Type == v3.AttributeKeyTypeResource {
return fmt.Sprintf(`resource["%s"]`, v.Key)
func getName(key *telemetrytypes.TelemetryFieldKey) string {
if key == nil {
return ""
}
return v.Key
}
func getTypeName(v v3.AttributeKeyType) string {
if v == v3.AttributeKeyTypeTag {
return "attributes"
} else if v == v3.AttributeKeyTypeResource {
return "resource"
switch key.FieldContext {
case telemetrytypes.FieldContextAttribute:
return fmt.Sprintf(`attributes["%s"]`, key.Name)
case telemetrytypes.FieldContextResource:
return fmt.Sprintf(`resource["%s"]`, key.Name)
case telemetrytypes.FieldContextBody:
return fmt.Sprintf("%s.%s", key.FieldContext.StringValue(), key.Name)
default:
return key.Name
}
return ""
}
func Parse(filters *v3.FilterSet) (string, error) {
var res []string
for _, v := range filters.Items {
if _, ok := logOperatorsToExpr[v.Operator]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %s", v.Operator)
}
func parseCondition(c qbtypes.FilterCondition) (string, error) {
if c.Key == nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "condition has no keys")
}
name := getName(v.Key)
if _, ok := logOperatorsToExpr[c.Op]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %d", c.Op)
}
var filter string
value := exprFormattedValue(c.Value)
var filter string
switch v.Operator {
// uncomment following lines when new version of expr is used
// case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
// filter = fmt.Sprintf("%s %s list%s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
switch c.Op {
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
// EXISTS/NOT EXISTS checks are special:
// - For body fields, we check membership in fromJSON(body) using the JSON field name.
// - For attribute/resource fields, we check membership in the appropriate map
// ("attributes" or "resource") using the logical field name.
// - For intrinsic / toplevel fields (no explicit context), we fall back to
// equality against nil (see default case below).
switch c.Key.FieldContext {
case telemetrytypes.FieldContextBody:
// if body is a string and is a valid JSON, then check if the key exists in the JSON
quoted := exprFormattedValue(c.Key.Name)
jsonMembership := fmt.Sprintf(
`((type(body) == "string" && isJSON(body)) && %s %s %s)`,
quoted, logOperatorsToExpr[c.Op], "fromJSON(body)",
)
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
// accustom log filters like `body.log.message EXISTS` into EXPR language
// where User is attempting to check for keys present in JSON log body
if strings.HasPrefix(v.Key.Key, "body.") {
// if body is a string and is a valid JSON, then check if the key exists in the JSON
filter = fmt.Sprintf(`((type(body) == "string" && isJSON(body)) && %s %s %s)`, exprFormattedValue(strings.TrimPrefix(v.Key.Key, "body.")), logOperatorsToExpr[v.Operator], "fromJSON(body)")
// if body is a map, then check if the key exists in the map
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
nilCheckFilter := fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, filter, nilCheckFilter)
} else if typ := getTypeName(v.Key.Type); typ != "" {
filter = fmt.Sprintf("%s %s %s", exprFormattedValue(v.Key.Key), logOperatorsToExpr[v.Operator], typ)
} else {
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
// if body is a map, then check if the key exists in the map
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
nilCheckFilter := fmt.Sprintf("%s.%s %s nil", c.Key.FieldContext.StringValue(), c.Key.Name, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, jsonMembership, nilCheckFilter)
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource:
// Example: "http.method" in attributes
target := "resource"
if c.Key.FieldContext == telemetrytypes.FieldContextAttribute {
target = "attributes"
}
filter = fmt.Sprintf("%q %s %s", c.Key.Name, logOperatorsToExpr[c.Op], target)
default:
filter = fmt.Sprintf("%s %s %s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
if v.Operator == v3.FilterOperatorContains || v.Operator == v3.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value),
)
}
// Avoid running operators on nil values
if v.Operator != v3.FilterOperatorEqual && v.Operator != v3.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", name, filter)
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", c.Key.Name, logOperatorsToExpr[operator])
}
default:
filter = fmt.Sprintf("%s %s %s", getName(c.Key), logOperatorsToExpr[c.Op], value)
if c.Op == qbtypes.FilterOperatorContains || c.Op == qbtypes.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
getName(c.Key), logOperatorsToExpr[c.Op], value,
)
}
// check if the filter is a correct expression language
_, err := expr.Compile(filter)
if err != nil {
return "", err
// Avoid running operators on nil values
if c.Op != qbtypes.FilterOperatorEqual && c.Op != qbtypes.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", getName(c.Key), filter)
}
res = append(res, filter)
}
// check the final filter
q := strings.Join(res, " "+strings.ToLower(filters.Operator)+" ")
_, err := expr.Compile(q)
_, err := expr.Compile(filter)
if err != nil {
return "", errors.WrapInternalf(err, CodeExprCompilationFailed, "failed to compile expression: %s", q)
return "", err
}
return filter, nil
}
// Parse converts the QB filter Expression (query builder expression string) into
// the Expr expression string used by the collector. It parses the QB expression
// into a FilterExprNode tree, then serializes that tree to the Expr dialect.
func Parse(filter *qbtypes.Filter) (string, error) {
if filter == nil || strings.TrimSpace(filter.Expression) == "" {
return "", nil
}
node, err := querybuilder.ParseFilterExpr(filter.Expression)
if err != nil {
return "", err
}
if node == nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter expression; failed to extract filter expression tree")
}
return q, nil
for _, condition := range node.Flatten() {
if condition.Key == nil {
return "", errors.NewInvalidInputf(errors.CodeInternal, "condition has no key")
}
_, found := telemetrylogs.IntrinsicFields[condition.Key.Name]
if condition.Key.FieldContext == telemetrytypes.FieldContextUnspecified && !found {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "field %q in filter expression must include a context prefix (attribute., resource., body.) OR can be one of the following fields: %v", condition.Key.Name, maps.Keys(telemetrylogs.IntrinsicFields))
}
if condition.Op == qbtypes.FilterOperatorRegexp || condition.Op == qbtypes.FilterOperatorNotRegexp {
switch condition.Value.(type) {
case string:
if _, err := regexp.Compile(condition.Value.(string)); err != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a valid regex")
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a string")
}
}
}
return nodeToExpr(node)
}
func nodeToExpr(node *qbtypes.FilterExprNode) (string, error) {
if node == nil {
return "", nil
}
var result string
switch node.Op {
case qbtypes.LogicalOpLeaf:
var parts []string
for _, c := range node.Conditions {
s, err := parseCondition(c)
if err != nil {
return "", err
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
// For a simple leaf, just join conditions with AND without wrapping
// the whole clause in parentheses
result = strings.Join(parts, " and ")
case qbtypes.LogicalOpAnd:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any OR child to
// preserve the intended precedence: (a and (b or c)).
if child.Op == qbtypes.LogicalOpOr {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
result = strings.Join(parts, " and ")
case qbtypes.LogicalOpOr:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any AND child to
// preserve the intended precedence: ((a and b) or c).
if child.Op == qbtypes.LogicalOpAnd {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
result = strings.Join(parts, " or ")
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported logical op: %s", node.Op)
}
if node.Negated {
// Apply a leading NOT to this subtree. Always wrap the underlying
// expression in parentheses to preserve the intended precedence.
return fmt.Sprintf("not (%s)", result), nil
}
return result, nil
}
func exprFormattedValue(v interface{}) string {
@@ -135,13 +254,12 @@ func exprFormattedValue(v interface{}) string {
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
return fmt.Sprintf("%d", x)
case float32, float64:
return fmt.Sprintf("%f", x)
return fmt.Sprintf("%v", x)
case string:
return fmt.Sprintf("\"%s\"", quoteEscapedString(x))
case bool:
return fmt.Sprintf("%v", x)
case []interface{}:
case []any:
if len(x) == 0 {
return ""
}

View File

@@ -4,7 +4,7 @@ import (
"testing"
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/expr-lang/expr/vm"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/assert"
@@ -13,183 +13,191 @@ import (
func TestParseExpression(t *testing.T) {
var testCases = []struct {
Name string
Query *v3.FilterSet
Query *qbtypes.Filter
Expr string
ExpectError bool
}{
{
Name: "equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key = 'checkbody'",
},
Expr: `attributes["key"] == "checkbody"`,
},
{
Name: "NOT equal (unary NOT)",
Query: &qbtypes.Filter{
Expression: "NOT (attribute.key = 'checkbody')",
},
Expr: `not (attributes["key"] == "checkbody")`,
},
{
Name: "not equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "!="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key != 'checkbody'",
},
Expr: `attributes["key"] != "checkbody"`,
},
{
Name: "less than",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key < 10",
},
Expr: `attributes["key"] != nil && attributes["key"] < 10`,
},
{
Name: "greater than",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key > 10",
},
Expr: `attributes["key"] != nil && attributes["key"] > 10`,
},
{
Name: "less than equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10",
},
Expr: `attributes["key"] != nil && attributes["key"] <= 10`,
},
{
Name: "greater than equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key >= 10",
},
Expr: `attributes["key"] != nil && attributes["key"] >= 10`,
},
// case sensitive
{
Name: "body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
Expr: `body != nil && lower(body) contains lower("checkbody")`,
},
{
Name: "body.log.message exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "body.log.message exists",
},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" in fromJSON(body)) or (type(body) == "map" && (body.log.message != nil)))`,
},
{
Name: "body.log.message not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "body.log.message not exists",
},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" not in fromJSON(body)) or (type(body) == "map" && (body.log.message == nil)))`,
},
{
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
Expr: `body != nil && lower(body) not contains lower("checkbody")`,
},
{
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "regex"},
}},
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex$'",
},
Expr: `body != nil && body matches "[0-1]+regex$"`,
},
{
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
}},
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex$'",
},
Expr: `body != nil && body not matches "[0-1]+regex$"`,
},
{
Name: "regex with escape characters",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: `^Executing \[\S+@\S+:[0-9]+\] \S+".*`, Operator: "regex"},
}},
Query: &qbtypes.Filter{
Expression: "body REGEXP '^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*'",
},
Expr: `body != nil && body matches "^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*"`,
},
{
Name: "invalid regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
}},
Expr: `body != nil && lower(body) not matches "[0-9]++"`,
Query: &qbtypes.Filter{
Expression: "body not REGEXP '[0-9]++'",
},
ExpectError: true,
},
{
Name: "in",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{1, 2, 3, 4}, Operator: "in"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key in [1,2,3,4]",
},
Expr: `attributes["key"] != nil && attributes["key"] in [1,2,3,4]`,
},
{
Name: "not in",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"1", "2"}, Operator: "nin"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key not in ['1','2']",
},
Expr: `attributes["key"] != nil && attributes["key"] not in ['1','2']`,
},
{
Name: "exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key exists",
},
Expr: `"key" in attributes`,
},
{
Name: "not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key not exists",
},
Expr: `"key" not in attributes`,
},
{
Name: "trace_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
Expr: `trace_id == nil`,
},
{
Name: "trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
Expr: `trace_id != nil`,
},
{
Name: "span_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
Expr: `span_id == nil`,
},
{
Name: "span_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
Expr: `span_id != nil`,
},
{
Name: "Multi filter",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-1]+regex$' and attribute.key not exists",
},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body != nil && body not matches "[0-1]+regex$" and "key" not in attributes`,
},
{
Name: "incorrect multi filter",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body not matches "[0-9]++" and "key" not in attributes`,
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-9]++' and attribute.key not exists",
},
ExpectError: true,
},
{
Name: "attributes. is unsupported",
Query: &qbtypes.Filter{
Expression: "attributes.key = 'checkbody'",
},
ExpectError: true,
},
}
@@ -267,248 +275,241 @@ func TestExpressionVSEntry(t *testing.T) {
var testCases = []struct {
Name string
Query *v3.FilterSet
Query *qbtypes.Filter
ExpectedMatches []int
}{
{
Name: "resource equal (env)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod'",
},
ExpectedMatches: []int{0, 1, 4, 5, 9, 11, 13, 14, 17, 18},
},
{
Name: "NOT resource equal (unary NOT)",
Query: &qbtypes.Filter{
Expression: "NOT (resource.env = 'prod')",
},
ExpectedMatches: []int{2, 3, 6, 7, 8, 10, 12, 15, 16, 19, 20},
},
{
Name: "resource not equal (env)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "!="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env != 'prod'",
},
ExpectedMatches: []int{2, 3, 6, 7, 8, 10, 12, 15, 16, 19, 20},
},
{
Name: "attribute less than (numeric)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: "<"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.count < 8",
},
ExpectedMatches: []int{4},
},
{
Name: "attribute greater than (numeric)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: ">"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.count > 8",
},
ExpectedMatches: []int{5},
},
{
Name: "body contains (case insensitive)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
ExpectedMatches: []int{2, 9, 10, 16},
},
{
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
ExpectedMatches: []int{0, 1, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 17},
},
{
Name: "body.msg (case insensitive)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: false}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body.msg contains 'checkbody'",
},
ExpectedMatches: []int{2, 9, 10, 18},
},
{
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
}},
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex'",
},
ExpectedMatches: []int{4},
},
{
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "nregex"},
}},
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex'",
},
ExpectedMatches: []int{0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17},
},
// body.log.message exists/nexists: expr checks "log.message" in fromJSON(body); nested key
// semantics depend on signoz stanza helper. Omitted here to avoid coupling to env shape.
{
Name: "body top-level key exists (body.msg)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "body.msg exists",
},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 18},
},
{
Name: "body top-level key not exists (body.missing)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.missing", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "body.missing not exists",
},
ExpectedMatches: []int{0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 18, 20},
},
{
Name: "attribute exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service exists",
},
ExpectedMatches: []int{6, 7, 8, 15},
},
{
Name: "attribute not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service not exists",
},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20},
},
{
Name: "trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
ExpectedMatches: []int{1, 2, 5, 7, 12, 15, 19},
},
{
Name: "trace_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
ExpectedMatches: []int{0, 3, 4, 6, 8, 9, 10, 11, 13, 14, 16, 17, 18, 20},
},
{
Name: "span_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
ExpectedMatches: []int{1, 3, 5, 12, 17, 20},
},
{
Name: "span_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
ExpectedMatches: []int{0, 2, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 18, 19},
},
{
Name: "in (attribute in list)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"info", "error"}, Operator: "in"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level in ['info', 'error']",
},
ExpectedMatches: []int{0, 1, 2, 14, 16, 20},
},
{
Name: "not in (attribute not in list)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"error", "warn"}, Operator: "nin"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level not in ['error', 'warn']",
},
ExpectedMatches: []int{0, 2, 3, 16, 20},
},
{
Name: "multi filter AND",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and attribute.level = 'info'",
},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND (two attributes)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service = 'auth' and attribute.level not exists",
},
ExpectedMatches: []int{6, 7},
},
// Multi-filter variations: body + attribute, three conditions, trace/span + attribute
{
Name: "multi filter AND body contains + attribute",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "Connection", Operator: "contains"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'Connection' and resource.env = 'prod'",
},
ExpectedMatches: []int{14},
},
{
Name: "multi filter AND body contains + service",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "login", Operator: "contains"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'login' and attribute.service = 'auth'",
},
ExpectedMatches: []int{6, 15},
},
{
Name: "multi filter AND env + level (prod error)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "error", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod' and attribute.level = 'error'",
},
ExpectedMatches: []int{1, 14},
},
{
Name: "multi filter AND three conditions (staging + checkbody + info)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and body contains 'checkbody' and attribute.level = 'info'",
},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND trace_id exists + body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists and body contains 'checkbody'",
},
ExpectedMatches: []int{2},
},
{
Name: "multi filter AND span_id nexists + service auth",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists and attribute.service = 'auth'",
},
ExpectedMatches: []int{6, 7, 15},
},
{
Name: "multi filter AND body regex + attribute",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "200", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body regexp '[0-1]+regex' and attribute.code = '200'",
},
ExpectedMatches: []int{4},
},
{
Name: "multi filter AND no trace_id + no span_id + env prod",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists and span_id not exists and resource.env = 'prod'",
},
ExpectedMatches: []int{0, 4, 9, 11, 13, 14, 18},
},
{
Name: "multi filter AND level warn + body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "warn", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "disk", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level = 'warn' and body contains 'disk'",
},
ExpectedMatches: []int{17},
},
{
Name: "no matches (attribute value not present)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "never", Operator: "="},
}},
Name: "no matches (resource value not present)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'never'",
},
ExpectedMatches: []int{},
},
{
Name: "attribute equal and trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "404", Operator: "="},
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.code = '404' and trace_id exists",
},
ExpectedMatches: []int{5},
},
}

View File

@@ -0,0 +1,390 @@
package querybuilder
import (
"fmt"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// ParseFilterExpr parses a v5 filter expression and returns a logical
// tree that preserves full boolean structure (AND/OR, parentheses, NOT) as
// well as the individual conditions (keys, operator, values).
//
// This can be reused by callers that need deeper introspection into the logic
// of the filter expression without constructing any SQL or query-engine
// specific structures.
func ParseFilterExpr(expr string) (*qbtypes.FilterExprNode, error) {
if strings.TrimSpace(expr) == "" {
return qbtypes.NewEmptyFilterExprNode(), nil
}
// Setup the ANTLR parsing pipeline (same grammar as PrepareWhereClause).
input := antlr.NewInputStream(expr)
lexer := grammar.NewFilterQueryLexer(input)
lexerErrorListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
// Handle syntax errors
if len(parserErrorListener.SyntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())
}
}
return nil, combinedErrors.WithAdditional(additionals...)
}
visitor := &filterTreeVisitor{}
rootAny := visitor.Visit(tree)
if len(visitor.errors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d errors while parsing the search expression.",
len(visitor.errors),
)
return nil, combinedErrors.WithAdditional(visitor.errors...)
}
root, _ := rootAny.(*qbtypes.FilterExprNode)
return root, nil
}
// filterTreeVisitor builds a FilterExprNode tree from the parse tree.
type filterTreeVisitor struct {
errors []string
}
// Visit dispatches based on node type.
func (v *filterTreeVisitor) Visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
switch t := tree.(type) {
case *grammar.QueryContext:
return v.VisitQuery(t)
case *grammar.ExpressionContext:
return v.VisitExpression(t)
case *grammar.OrExpressionContext:
return v.VisitOrExpression(t)
case *grammar.AndExpressionContext:
return v.VisitAndExpression(t)
case *grammar.UnaryExpressionContext:
return v.VisitUnaryExpression(t)
case *grammar.PrimaryContext:
return v.VisitPrimary(t)
case *grammar.ComparisonContext:
return v.VisitComparison(t)
default:
return nil
}
}
func (v *filterTreeVisitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.Visit(ctx.Expression())
}
func (v *filterTreeVisitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.Visit(ctx.OrExpression())
}
func (v *filterTreeVisitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
andExprs := ctx.AllAndExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(andExprs))
for _, andExpr := range andExprs {
if node, ok := v.Visit(andExpr).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: children,
}
}
func (v *filterTreeVisitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
unaryExprs := ctx.AllUnaryExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(unaryExprs))
for _, unary := range unaryExprs {
if node, ok := v.Visit(unary).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: children,
}
}
func (v *filterTreeVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
node, _ := v.Visit(ctx.Primary()).(*qbtypes.FilterExprNode)
if node == nil {
return nil
}
if ctx.NOT() != nil {
node.Negated = !node.Negated
}
return node
}
func (v *filterTreeVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
switch {
case ctx.OrExpression() != nil:
return v.Visit(ctx.OrExpression())
case ctx.Comparison() != nil:
return v.Visit(ctx.Comparison())
default:
// We intentionally ignore FullText/FunctionCall here for the tree
// representation. They can be added later if needed.
return nil
}
}
// VisitComparison builds a leaf node with a single ParsedFilterCondition.
func (v *filterTreeVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := v.buildKey(ctx.Key())
if key == nil {
return nil
}
// Handle EXISTS specially
if ctx.EXISTS() != nil {
op := qbtypes.FilterOperatorExists
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotExists
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: nil,
}},
}
}
// Handle IN / NOT IN
if ctx.InClause() != nil || ctx.NotInClause() != nil {
values := v.buildValuesFromInClause(ctx.InClause(), ctx.NotInClause())
op := qbtypes.FilterOperatorIn
if ctx.NotInClause() != nil {
op = qbtypes.FilterOperatorNotIn
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: values,
}},
}
}
// Handle BETWEEN / NOT BETWEEN
if ctx.BETWEEN() != nil {
valuesCtx := ctx.AllValue()
if len(valuesCtx) != 2 {
v.errors = append(v.errors, "BETWEEN operator requires exactly two values")
return nil
}
value1 := v.buildValue(valuesCtx[0])
value2 := v.buildValue(valuesCtx[1])
op := qbtypes.FilterOperatorBetween
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotBetween
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: []any{value1, value2},
}},
}
}
// All remaining operators have exactly one value.
valuesCtx := ctx.AllValue()
if len(valuesCtx) == 0 {
v.errors = append(v.errors, "comparison operator requires a value")
return nil
}
value := v.buildValue(valuesCtx[0])
var op qbtypes.FilterOperator
switch {
case ctx.EQUALS() != nil:
op = qbtypes.FilterOperatorEqual
case ctx.NOT_EQUALS() != nil || ctx.NEQ() != nil:
op = qbtypes.FilterOperatorNotEqual
case ctx.LT() != nil:
op = qbtypes.FilterOperatorLessThan
case ctx.LE() != nil:
op = qbtypes.FilterOperatorLessThanOrEq
case ctx.GT() != nil:
op = qbtypes.FilterOperatorGreaterThan
case ctx.GE() != nil:
op = qbtypes.FilterOperatorGreaterThanOrEq
case ctx.LIKE() != nil:
op = qbtypes.FilterOperatorLike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotLike
}
case ctx.ILIKE() != nil:
op = qbtypes.FilterOperatorILike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotILike
}
case ctx.REGEXP() != nil:
op = qbtypes.FilterOperatorRegexp
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotRegexp
}
case ctx.CONTAINS() != nil:
op = qbtypes.FilterOperatorContains
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotContains
}
default:
v.errors = append(v.errors, fmt.Sprintf("unsupported comparison operator in expression: %s", ctx.GetText()))
return nil
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Key: key,
Op: op,
Value: value,
}},
}
}
// buildKey turns a key context into a TelemetryFieldKey.
func (v *filterTreeVisitor) buildKey(ctx grammar.IKeyContext) *telemetrytypes.TelemetryFieldKey {
if ctx == nil {
return nil
}
key := telemetrytypes.GetFieldKeyFromKeyText(ctx.GetText())
return &key
}
// buildValuesFromInClause handles the IN/NOT IN value side.
func (v *filterTreeVisitor) buildValuesFromInClause(in grammar.IInClauseContext, notIn grammar.INotInClauseContext) []any {
var ctxVal any
if in != nil {
ctxVal = v.visitInClause(in)
} else if notIn != nil {
ctxVal = v.visitNotInClause(notIn)
}
switch ret := ctxVal.(type) {
case []any:
return ret
case any:
if ret != nil {
return []any{ret}
}
}
return nil
}
func (v *filterTreeVisitor) visitInClause(ctx grammar.IInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitNotInClause(ctx grammar.INotInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitValueList(ctx grammar.IValueListContext) any {
values := ctx.AllValue()
parts := make([]any, 0, len(values))
for _, val := range values {
parts = append(parts, v.buildValue(val))
}
return parts
}
// buildValue converts literal values into Go types (string, float64, bool).
func (v *filterTreeVisitor) buildValue(ctx grammar.IValueContext) any {
switch {
case ctx == nil:
return nil
case ctx.QUOTED_TEXT() != nil:
txt := ctx.QUOTED_TEXT().GetText()
return trimQuotes(txt)
case ctx.NUMBER() != nil:
number, err := strconv.ParseFloat(ctx.NUMBER().GetText(), 64)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to parse number %s", ctx.NUMBER().GetText()))
return nil
}
return number
case ctx.BOOL() != nil:
boolText := strings.ToLower(ctx.BOOL().GetText())
return boolText == "true"
case ctx.KEY() != nil:
return ctx.KEY().GetText()
default:
return nil
}
}

View File

@@ -0,0 +1,355 @@
package querybuilder
import (
"reflect"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// helper to build a simple key with unspecified context/datatype.
func fk(_ *testing.T, name string) *telemetrytypes.TelemetryFieldKey {
key := telemetrytypes.GetFieldKeyFromKeyText(name)
return &key
}
func TestParseFilterExpr_NestedConditions(t *testing.T) {
tests := []struct {
name string
expr string
want *qbtypes.FilterExprNode
wantErr bool
}{
{
name: "empty expression returns nil",
expr: " ",
want: qbtypes.NewEmptyFilterExprNode(),
},
{
name: "invalid expression returns error",
expr: "attributes.key =",
wantErr: true,
},
{
name: "single simple equality leaf",
expr: "attributes.host.name = 'frontend'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.host.name"),
Op: qbtypes.FilterOperatorEqual,
Value: "frontend",
},
},
},
},
{
name: "AND with BETWEEN numeric range and NOT boolean comparison",
expr: "attributes.status_code BETWEEN 500 AND 599 AND NOT attributes.is_error = true",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status_code"),
Op: qbtypes.FilterOperatorBetween,
Value: []any{
float64(500),
float64(599),
},
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.is_error"),
Op: qbtypes.FilterOperatorEqual,
Value: true,
},
},
},
},
},
},
{
name: "OR with IN list and NOT IN list using different syntaxes",
expr: "attributes.service.name IN ('api','worker') OR resource.region NOT IN [\"us-east-1\",\"us-west-2\"]",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.service.name"),
Op: qbtypes.FilterOperatorIn,
Value: []any{
"api",
"worker",
},
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorNotIn,
Value: []any{
"us-east-1",
"us-west-2",
},
},
},
},
},
},
},
{
name: "AND chain of string pattern operators with NOT variants",
expr: "attributes.message LIKE 'error%' AND attributes.message NOT ILIKE '%debug%' AND attributes.message REGEXP 'err[0-9]+' AND attributes.message NOT CONTAINS 'trace'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorLike,
Value: "error%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorNotILike,
Value: "%debug%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorRegexp,
Value: "err[0-9]+",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.message"),
Op: qbtypes.FilterOperatorNotContains,
Value: "trace",
},
},
},
},
},
},
{
name: "EXISTS and NOT EXISTS in OR expression",
expr: "attributes.host EXISTS OR attributes.cluster NOT EXISTS",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.host"),
Op: qbtypes.FilterOperatorExists,
Value: nil,
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.cluster"),
Op: qbtypes.FilterOperatorNotExists,
Value: nil,
},
},
},
},
},
},
{
name: "KEY used as value in equality",
expr: "attributes.left = other_key",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.left"),
Op: qbtypes.FilterOperatorEqual,
Value: "other_key",
},
},
},
},
{
name: "nested OR inside AND with NOT on inner group",
// NOT applies to the whole parenthesized OR group.
expr: "attributes.env = 'prod' AND NOT (attributes.team = 'core' OR attributes.team = 'platform')",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.env"),
Op: qbtypes.FilterOperatorEqual,
Value: "prod",
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.team"),
Op: qbtypes.FilterOperatorEqual,
Value: "core",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.team"),
Op: qbtypes.FilterOperatorEqual,
Value: "platform",
},
},
},
},
},
},
},
},
{
name: "multiple nesting levels mixing AND/OR",
expr: "(attributes.status = 'critical' OR attributes.status = 'warning') AND (resource.region = 'us-east-1' OR (resource.region = 'us-west-2' AND attributes.tier = 'backend'))",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status"),
Op: qbtypes.FilterOperatorEqual,
Value: "critical",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.status"),
Op: qbtypes.FilterOperatorEqual,
Value: "warning",
},
},
},
},
},
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorEqual,
Value: "us-east-1",
},
},
},
{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "resource.region"),
Op: qbtypes.FilterOperatorEqual,
Value: "us-west-2",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Key: fk(t, "attributes.tier"),
Op: qbtypes.FilterOperatorEqual,
Value: "backend",
},
},
},
},
},
},
},
},
},
},
{
name: "random test",
expr: "attributes.status, attributes.status_code = 200",
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got, err := ParseFilterExpr(tt.expr)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("unexpected tree for expr %q\n got: %#v\n want: %#v", tt.expr, got, tt.want)
}
})
}
}

View File

@@ -169,6 +169,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAnonymousPublicDashboardTransactionFactory(sqlstore),
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigratePipelineFiltersV5Factory(sqlstore),
)
}

View File

@@ -0,0 +1,135 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/factory"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migratePipelineFiltersV5 struct {
store sqlstore.SQLStore
logger *slog.Logger
}
func NewMigratePipelineFiltersV5Factory(
store sqlstore.SQLStore,
) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_pipeline_filters_v5"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newMigratePipelineFiltersV5(ctx, c, store, ps.Logger)
},
)
}
func newMigratePipelineFiltersV5(
_ context.Context,
_ Config,
store sqlstore.SQLStore,
logger *slog.Logger,
) (SQLMigration, error) {
if logger == nil {
logger = slog.Default()
}
return &migratePipelineFiltersV5{
store: store,
logger: logger,
}, nil
}
func (migration *migratePipelineFiltersV5) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *migratePipelineFiltersV5) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var pipelines []pipelinetypes.StoreablePipeline
if err := tx.NewSelect().
Table("pipelines").
Column("id", "filter").
Scan(ctx, &pipelines); err != nil {
return err
}
for _, p := range pipelines {
raw := strings.TrimSpace(p.FilterString)
if raw == "" {
continue
}
// Skip migration if this already looks like a v5 filter
// ({ "expression": "..." }), but still persist the normalized form
// if we changed it.
var vf qbtypes.Filter
if err := json.Unmarshal([]byte(raw), &vf); err == nil && strings.TrimSpace(vf.Expression) != "" {
continue
}
var filterSet v3.FilterSet
if err := json.Unmarshal([]byte(raw), &filterSet); err != nil {
return err
}
// Attempt to treat the existing JSON as a v3 FilterSet and build a v5
// expression from it, using the normalized payload.
expr, migrated, err := transition.BuildFilterExpressionFromFilterSet(ctx, migration.logger, "logs", &filterSet)
if err != nil || !migrated || strings.TrimSpace(expr) == "" {
return err
}
filter := &qbtypes.Filter{Expression: expr}
_, err = queryBuilderToExpr.Parse(filter)
if err != nil {
return err
}
// Store back as v5-style Filter JSON.
out, err := json.Marshal(filter)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Table("pipelines").
Set("filter = ?", string(out)).
Where("id = ?", p.ID).
Exec(ctx); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *migratePipelineFiltersV5) Down(ctx context.Context, db *bun.DB) error {
// Not reversible: v3 FilterSet structure is lost once we convert to a v5 expression.
return nil
}

View File

@@ -0,0 +1,79 @@
package transition
import (
"context"
"encoding/json"
"log/slog"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
// BuildFilterExpressionFromFilterSet converts a v3-style FilterSet JSON
// ({"op": "...", "items": [...]}) into a v5-style filter expression string
// (for example: "attribute.http.method = 'GET' AND resource.env = 'prod'").
//
// It reuses migrateCommon.createFilterExpression so that all the existing
// semantics around operators, variables, data types, and ambiguity handling
// are preserved.
//
// dataSource determines which ambiguity set to use ("logs", "traces", etc.).
// For log pipelines, pass "logs".
//
// Returns:
// - expression: the generated filter expression string
// - migrated: true if an expression was generated, false if there was
// nothing to migrate (e.g. empty filters)
// - err: non-nil only if the input JSON could not be parsed
func BuildFilterExpressionFromFilterSet(
ctx context.Context,
logger *slog.Logger,
dataSource string,
filterSet *v3.FilterSet,
) (expression string, migrated bool, err error) {
if filterSet == nil {
return "", false, nil
}
filterJSON, err := json.Marshal(filterSet)
if err != nil {
return "", false, err
}
var filters map[string]any
if err := json.Unmarshal([]byte(filterJSON), &filters); err != nil {
return "", false, err
}
mc := NewMigrateCommon(logger)
// add keys with type into ambiguity set to preserve context in generated expression
for _, item := range filterSet.Items {
if item.Key.Type == v3.AttributeKeyTypeUnspecified {
continue
}
mc.ambiguity[dataSource] = append(mc.ambiguity[dataSource], item.Key.Key)
}
// Shape expected by migrateCommon.createFilterExpression:
// queryData["dataSource"] == "logs" | "traces" | "metrics"
// queryData["filters"] == map[string]any{"op": "...", "items": [...]}
queryData := map[string]any{
"dataSource": dataSource,
"filters": filters,
}
if !mc.createFilterExpression(ctx, queryData) {
return "", false, nil
}
filterAny, ok := queryData["filter"].(map[string]any)
if !ok {
return "", false, nil
}
expr, ok := filterAny["expression"].(string)
if !ok || expr == "" {
return "", false, nil
}
return expr, true, nil
}

View File

@@ -0,0 +1,220 @@
package transition
import (
"context"
"log/slog"
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
func TestBuildFilterExpressionFromFilterSet(t *testing.T) {
ctx := context.Background()
logger := slog.Default()
tests := []struct {
name string
dataSource string
filterSet *v3.FilterSet
wantExpr string
wantMigrated bool
wantErr bool
}{
{
name: "empty filter JSON",
dataSource: "logs",
filterSet: &v3.FilterSet{},
wantExpr: "",
wantMigrated: false,
wantErr: false,
},
{
name: "empty items array",
dataSource: "logs",
filterSet: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{}},
wantExpr: "",
wantMigrated: false,
wantErr: false,
},
{
name: "multiple filter items with AND operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "http.method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "GET",
},
{
Key: v3.AttributeKey{Key: "environment", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "prod",
},
},
},
wantExpr: `(attribute.http.method = 'GET' AND resource.environment = 'prod')`,
wantMigrated: true,
wantErr: false,
},
{
name: "multiple filter items with OR operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "OR",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "error",
},
{
Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "warn",
},
},
},
wantExpr: `(attribute.level = 'error' OR attribute.level = 'warn')`,
wantMigrated: true,
wantErr: false,
},
{
name: "in operator with array value",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "service.name", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorIn,
Value: []string{"api", "web", "worker"},
}},
},
wantExpr: `attribute.service.name IN ['api', 'web', 'worker']`,
wantMigrated: true,
wantErr: false,
},
{
name: "contains operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorContains,
Value: "error",
}},
},
wantExpr: `attribute.message CONTAINS 'error'`,
wantMigrated: true,
wantErr: false,
},
{
name: "not exists operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "trace.id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorNotExists,
Value: nil,
}},
},
wantExpr: `attribute.trace.id NOT EXISTS`,
wantMigrated: true,
wantErr: false,
},
{
name: "regex operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "body"},
Operator: v3.FilterOperatorRegex,
Value: ".*",
}},
},
wantExpr: `body REGEXP '.*'`,
wantMigrated: true,
wantErr: false,
},
{
name: "has operator",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "tags", DataType: v3.AttributeKeyDataTypeArrayString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorHas,
Value: "production",
}},
},
wantExpr: `has(attribute.tags, 'production')`,
wantMigrated: true,
wantErr: false,
},
{
name: "complex filter with multiple operators",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{Key: "http.method", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorEqual,
Value: "POST",
},
{
Key: v3.AttributeKey{Key: "http.status_code", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag},
Operator: v3.FilterOperatorGreaterThanOrEq,
Value: float64(400),
},
{
Key: v3.AttributeKey{Key: "resource.env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "prod",
},
},
},
wantExpr: `(attribute.http.method = 'POST' AND attribute.http.status_code >= 400 AND resource.resource.env = 'prod')`,
wantMigrated: true,
wantErr: false,
},
{
name: "filter with resource type (non-ambiguous key)",
dataSource: "logs",
filterSet: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{Key: "service.name", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource},
Operator: v3.FilterOperatorEqual,
Value: "frontend",
}},
},
wantExpr: `resource.service.name = 'frontend'`,
wantMigrated: true,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
expr, migrated, err := BuildFilterExpressionFromFilterSet(ctx, logger, tt.dataSource, tt.filterSet)
if (err != nil) != tt.wantErr {
t.Errorf("BuildFilterExpressionFromFilterSet() error = %v, wantErr %v", err, tt.wantErr)
return
}
if migrated != tt.wantMigrated {
t.Errorf("BuildFilterExpressionFromFilterSet() migrated = %v, want %v", migrated, tt.wantMigrated)
}
if expr != tt.wantExpr {
t.Errorf("BuildFilterExpressionFromFilterSet() expression = %v, want %v", expr, tt.wantExpr)
}
})
}
}

View File

@@ -19,7 +19,8 @@ type migrateCommon struct {
func NewMigrateCommon(logger *slog.Logger) *migrateCommon {
return &migrateCommon{
logger: logger,
logger: logger,
ambiguity: make(map[string][]string),
}
}

View File

@@ -7,9 +7,9 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/uptrace/bun"
)
@@ -57,7 +57,7 @@ type StoreablePipeline struct {
type GettablePipeline struct {
StoreablePipeline
Filter *v3.FilterSet `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -72,7 +72,7 @@ func (i *GettablePipeline) ParseRawConfig() error {
}
func (i *GettablePipeline) ParseFilter() error {
f := v3.FilterSet{}
f := qbtypes.Filter{}
err := json.Unmarshal([]byte(i.FilterString), &f)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse filter")
@@ -200,7 +200,7 @@ type PostablePipeline struct {
Alias string `json:"alias"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Filter *v3.FilterSet `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -218,6 +218,14 @@ func (p *PostablePipeline) IsValid() error {
}
// check the filter
if p.Filter == nil || strings.TrimSpace(p.Filter.Expression) == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "filter.expression is required")
}
// Validate that every field in the expression has an explicit context
// (attribute., resource., body., etc) so later pipeline processing does not
// need to guess. We do not validate that the field actually
// exists only that the context is specified.
_, err := queryBuilderToExpr.Parse(p.Filter)
if err != nil {
return err

View File

@@ -3,24 +3,13 @@ package pipelinetypes
import (
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
. "github.com/smartystreets/goconvey/convey"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestIsValidPostablePipeline(t *testing.T) {
validPipelineFilterSet := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
validPipelineFilter := &qbtypes.Filter{
Expression: `attribute.method = "GET"`,
}
var correctQueriesTest = []struct {
@@ -34,7 +23,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -46,7 +35,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -58,7 +47,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: true,
@@ -70,19 +59,21 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
},
Operator: "regex",
Value: "[0-9A-Z*",
},
},
Filter: &qbtypes.Filter{
Expression: "",
},
},
IsValid: false,
},
{
Name: "Filter without context prefix on field",
Pipeline: PostablePipeline{
OrderID: 1,
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &qbtypes.Filter{
Expression: `method = "GET"`,
},
},
IsValid: false,
@@ -94,19 +85,19 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
},
IsValid: true,
},
}
for _, test := range correctQueriesTest {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := test.Pipeline.IsValid()
if test.IsValid {
So(err, ShouldBeNil)
assert.NoError(t, err)
} else {
So(err, ShouldBeError)
assert.Error(t, err)
}
})
}
@@ -365,12 +356,12 @@ var operatorTest = []struct {
func TestValidOperator(t *testing.T) {
for _, test := range operatorTest {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := isValidOperator(test.Operator)
if test.IsValid {
So(err, ShouldBeNil)
assert.NoError(t, err)
} else {
So(err, ShouldBeError)
assert.Error(t, err)
}
})
}

View File

@@ -0,0 +1,66 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
// LogicalOp represents how child expressions are combined.
type LogicalOp string
const (
// LogicalOpLeaf represents a leaf node containing one or more simple conditions.
LogicalOpLeaf LogicalOp = "LEAF"
// LogicalOpAnd represents an AND combination of children.
LogicalOpAnd LogicalOp = "AND"
// LogicalOpOr represents an OR combination of children.
LogicalOpOr LogicalOp = "OR"
)
// FilterExprNode is a reusable logical representation of a filter expression.
//
// - Leaf nodes (Op == LogicalOpLeaf) contain one or more ParsedFilterCondition.
// - Non-leaf nodes (Op == LogicalOpAnd/LogicalOpOr) contain Children.
// - Negated indicates a leading NOT applied to this subtree.
type FilterExprNode struct {
Op LogicalOp
Negated bool
Conditions []FilterCondition
Children []*FilterExprNode
}
func NewEmptyFilterExprNode() *FilterExprNode {
return &FilterExprNode{
Op: LogicalOpLeaf,
}
}
func (f *FilterExprNode) Flatten() []FilterCondition {
var conditions []FilterCondition
var walk func(node *FilterExprNode)
walk = func(node *FilterExprNode) {
if node == nil {
return
}
if node.Op == LogicalOpLeaf {
conditions = append(conditions, node.Conditions...)
}
for _, child := range node.Children {
walk(child)
}
}
walk(f)
return conditions
}
// FilterCondition represents a single comparison or existence check
// extracted from a filter expression.
//
// - Keys: one or more logical field keys on the left-hand side (see where_clause_visitor.VisitKey
// for why one expression key can resolve to multiple TelemetryFieldKeys).
// - Op: filter operator (e.g. =, !=, in, exists, between).
// - Value: right-hand side literal (any type: single value, slice for IN/NOT IN, nil for EXISTS, etc.).
type FilterCondition struct {
Key *telemetrytypes.TelemetryFieldKey
Op FilterOperator
Value any
}