Compare commits

...

17 Commits

Author SHA1 Message Date
primus-bot[bot]
131759ec96 chore(release): bump to v0.80.0 (#7703)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-04-23 12:20:51 +05:30
Shivanshu Raj Shrivastava
365a3e250f chore: fix error rate (#7701)
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-22 20:26:37 +00:00
Nityananda Gohain
f3a1f3cc20 fix: handle rate operators for table panel (#7695)
* fix: handle rate operators for table panel
2025-04-22 19:09:08 +00:00
Aditya Singh
ae509b4ae9 Resource attr filter: style fix and quick filter changes (#7691)
* chore: resource attr filter init

* chore: resource attr filter api integration

* chore: operator config updated

* chore: fliter show hide logic and styles

* chore: add support for custom operator list to qb

* chore: minor refactor

* chore: minor code refactor

* test: quick filters test suite added

* test: quick filters test suite added

* test: all errors test suite added

* chore: style fix

* test: all errors mock fix

* chore: test case fix and mixpanel update

* chore: color update

* chore: minor refactor

* chore: style fix

* chore: set default query in exceptions tab

* chore: style fix

* chore: minor refactor

* chore: minor refactor

* chore: minor refactor

* chore: test update

* chore: fix filter header with no query name

* fix: scroll fix

* chore: add data source traces to quick filters

* chore: replace div with fragment

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-04-22 15:45:49 +00:00
Shaheer Kochai
43e2be0333 feat: use search v2 component for traces (#7537)
* Revert "fix: display same key with multiple data types in filter suggestions by enhancing the deduping logic (#7255)"

This reverts commit 1e85981a17.

* fix: use query search v2 for traces data source to handle multiple data types for the same key

* fix(QueryBuilderSearchV2): add user typed option if it doesn't exist in the payload

* fix(QueryBuilderSearchV2): increase the height of search dropdown for non-logs data sources

* fix: display span scope selector for trace data source

* chore: remove the span scope selector from qb search v1 and move the component to search v2

* fix: write test to ensure that we display span scope selector for traces data source

* fix: limit converting  ->   only to log data source

* fix: don't display empty suggestion if only spaces are typed

* chore: tests for span scope selector

* chore: qb search flow (key, operator, value) test cases

* refactor: fix the Maximum update depth reached issue while running tests

* chore: overall improvements to span scope selector tests
2025-04-22 15:24:03 +00:00
Yunus M
20a40b33ce chore: update copy webpack plugin (#7687)
* chore: update copy webpack plugin
2025-04-22 20:36:57 +05:30
sawhil
a9b07c4b47 feat: added test case for checking copying functionality 2025-04-22 15:18:38 +05:30
sawhil
2a5c7cc0ab feat: added test cases for copy span link functionality 2025-04-22 15:18:38 +05:30
sawhil
afb18b8142 fix: pr comments - used useSafeNavigate hook 2025-04-22 15:18:38 +05:30
sawhil
9a580915e6 fix: removed not required prop 2025-04-22 15:18:38 +05:30
sawhil
0944af3d31 feat: added copy span link support alongside span click expand in waterfall graph 2025-04-22 15:18:38 +05:30
SagarRajput-7
9338efcefc fix: boolean values are not shown in the list panel's column (#7668)
* fix: boolean values are not shown in the list panel's column

* fix: moved logic to component level

* fix: added type

* fix: added test cases

* fix: added test cases
2025-04-22 12:03:28 +05:30
sawhil
6b9e0ce799 fix: minor comment update 2025-04-21 12:45:04 +05:30
sawhil
d4c3c24849 feat: added test cases 2025-04-21 12:45:04 +05:30
sawhil
30d935a768 fix: used existing constant 2025-04-21 12:45:04 +05:30
sawhil
073d42c416 fix: removed timestamp and id from being passed to query from log details drawer 2025-04-21 12:45:04 +05:30
Aditya Singh
f11b9644cf Introduce new Resource Attribute FIlter in exceptions tab (#7589)
* chore: resource attr filter init

* chore: resource attr filter api integration

* chore: operator config updated

* chore: fliter show hide logic and styles

* chore: add support for custom operator list to qb

* chore: minor refactor

* chore: minor code refactor

* test: quick filters test suite added

* test: quick filters test suite added

* test: all errors test suite added

* chore: style fix

* test: all errors mock fix

* chore: test case fix and mixpanel update

* chore: color update

* chore: minor refactor

* chore: style fix

* chore: set default query in exceptions tab

* chore: style fix

* chore: minor refactor

* chore: minor refactor

* chore: minor refactor

* chore: test update

* chore: fix filter header with no query name

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-04-21 05:41:05 +00:00
53 changed files with 2414 additions and 137 deletions

View File

@@ -174,7 +174,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.79.1
image: signoz/signoz:v0.80.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.79.1
image: signoz/signoz:v0.80.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true

View File

@@ -177,7 +177,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.79.1}
image: signoz/signoz:${VERSION:-v0.80.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -110,7 +110,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.79.1}
image: signoz/signoz:${VERSION:-v0.80.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -198,7 +198,7 @@
"autoprefixer": "10.4.19",
"babel-plugin-styled-components": "^1.12.0",
"compression-webpack-plugin": "9.0.0",
"copy-webpack-plugin": "^8.1.0",
"copy-webpack-plugin": "^11.0.0",
"critters-webpack-plugin": "^3.0.1",
"eslint": "^7.32.0",
"eslint-config-airbnb": "^19.0.4",
@@ -255,6 +255,7 @@
"body-parser": "1.20.3",
"http-proxy-middleware": "3.0.3",
"cross-spawn": "7.0.5",
"cookie": "^0.7.1"
"cookie": "^0.7.1",
"serialize-javascript": "6.0.2"
}
}

View File

@@ -6,6 +6,7 @@ import {
VerticalAlignTopOutlined,
} from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { cloneDeep, isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -68,10 +69,14 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<section className="header">
<section className="left-actions">
<FilterOutlined />
<Typography.Text className="text">Filters for</Typography.Text>
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
<Typography.Text className="text">
{lastQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{lastQueryName && (
<Tooltip title={`Filter currently in sync with query ${lastQueryName}`}>
<Typography.Text className="sync-tag">{lastQueryName}</Typography.Text>
</Tooltip>
)}
</section>
<section className="right-actions">
@@ -89,31 +94,33 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</section>
)}
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
<TypicalOverlayScrollbar>
<section className="filters">
{config.map((filter) => {
switch (filter.type) {
case FiltersType.CHECKBOX:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
case FiltersType.SLIDER:
return <Slider filter={filter} />;
// eslint-disable-next-line sonarjs/no-duplicated-branches
default:
return (
<Checkbox
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>
);
}
})}
</section>
</TypicalOverlayScrollbar>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import QuickFilters from '../QuickFilters';
import { QuickFiltersSource } from '../types';
import { QuickFiltersConfig } from './constants';
// Mock the useQueryBuilder hook
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// Mock the useGetAggregateValues hook
jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
useGetAggregateValues: jest.fn(),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
function TestQuickFilters(): JSX.Element {
return (
<MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={QuickFiltersConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</MockQueryClientProvider>
);
}
describe('Quick Filters', () => {
beforeEach(() => {
// Provide a mock implementation for useQueryBuilder
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
queryName: 'Test Query',
filters: { items: [{ key: 'test', value: 'value' }] },
},
],
},
},
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
// Provide a mock implementation for useGetAggregateValues
(useGetAggregateValues as jest.Mock).mockReturnValue({
data: {
statusCode: 200,
error: null,
message: 'success',
payload: {
stringAttributeValues: [
'mq-kafka',
'otel-demo',
'otlp-python',
'sample-flask',
],
numberAttributeValues: null,
boolAttributeValues: null,
},
}, // Mocked API response
isLoading: false,
});
});
it('renders correctly with default props', () => {
const { container } = render(<TestQuickFilters />);
expect(container).toMatchSnapshot();
});
it('displays the correct query name in the header', () => {
render(<TestQuickFilters />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText('Test Query')).toBeInTheDocument();
});
it('should add filter data to query when checkbox is clicked', () => {
render(<TestQuickFilters />);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: {
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({
key: 'deployment.environment',
}),
value: 'mq-kafka',
}),
]),
}),
}),
]),
},
}),
); // sets composite query param
});
});

View File

@@ -0,0 +1,382 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Quick Filters renders correctly with default props 1`] = `
<div>
<div
class="quick-filters"
>
<section
class="header"
>
<section
class="left-actions"
>
<span
aria-label="filter"
class="anticon anticon-filter"
role="img"
>
<svg
aria-hidden="true"
data-icon="filter"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z"
/>
</svg>
</span>
<span
class="ant-typography text css-dev-only-do-not-override-2i2tap"
>
Filters for
</span>
<span
class="ant-typography sync-tag css-dev-only-do-not-override-2i2tap"
>
Test Query
</span>
</section>
<section
class="right-actions"
>
<span
aria-label="sync"
class="anticon anticon-sync sync-icon"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="sync"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M168 504.2c1-43.7 10-86.1 26.9-126 17.3-41 42.1-77.7 73.7-109.4S337 212.3 378 195c42.4-17.9 87.4-27 133.9-27s91.5 9.1 133.8 27A341.5 341.5 0 01755 268.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.7 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c0-6.7-7.7-10.5-12.9-6.3l-56.4 44.1C765.8 155.1 646.2 92 511.8 92 282.7 92 96.3 275.6 92 503.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8zm756 7.8h-60c-4.4 0-7.9 3.5-8 7.8-1 43.7-10 86.1-26.9 126-17.3 41-42.1 77.8-73.7 109.4A342.45 342.45 0 01512.1 856a342.24 342.24 0 01-243.2-100.8c-9.9-9.9-19.2-20.4-27.8-31.4l60.2-47a8 8 0 00-3-14.1l-175.7-43c-5-1.2-9.9 2.6-9.9 7.7l-.7 181c0 6.7 7.7 10.5 12.9 6.3l56.4-44.1C258.2 868.9 377.8 932 512.2 932c229.2 0 415.5-183.7 419.8-411.8a8 8 0 00-8-8.2z"
/>
</svg>
</span>
<div
class="divider-filter"
/>
<span
aria-label="vertical-align-top"
class="anticon anticon-vertical-align-top"
role="img"
tabindex="-1"
>
<svg
aria-hidden="true"
data-icon="vertical-align-top"
fill="currentColor"
focusable="false"
height="1em"
style="transform: rotate(270deg);"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M859.9 168H164.1c-4.5 0-8.1 3.6-8.1 8v60c0 4.4 3.6 8 8.1 8h695.8c4.5 0 8.1-3.6 8.1-8v-60c0-4.4-3.6-8-8.1-8zM518.3 355a8 8 0 00-12.6 0l-112 141.7a7.98 7.98 0 006.3 12.9h73.9V848c0 4.4 3.6 8 8 8h60c4.4 0 8-3.6 8-8V509.7H624c6.7 0 10.4-7.7 6.3-12.9L518.3 355z"
/>
</svg>
</span>
</section>
</section>
<div
class="overlay-scrollbar"
data-overlayscrollbars-initialize="true"
>
<div
data-overlayscrollbars-contents=""
>
<section
class="filters"
>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
class="lucide lucide-chevron-down"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m6 9 6 6 6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Environment
</span>
</section>
<section
class="right-action"
>
<span
class="ant-typography clear-all css-dev-only-do-not-override-2i2tap"
>
Clear All
</span>
</section>
</section>
<section
class="search"
>
<input
class="ant-input css-dev-only-do-not-override-2i2tap"
placeholder="Filter values"
type="text"
value=""
/>
</section>
<section
class="values"
>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
mq-kafka
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otel-demo
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
otlp-python
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
<div
class="value"
>
<label
class="ant-checkbox-wrapper ant-checkbox-wrapper-checked check-box css-dev-only-do-not-override-2i2tap"
>
<span
class="ant-checkbox ant-wave-target css-dev-only-do-not-override-2i2tap ant-checkbox-checked"
>
<input
checked=""
class="ant-checkbox-input"
type="checkbox"
/>
<span
class="ant-checkbox-inner"
/>
</span>
</label>
<div
class="checkbox-value-section"
>
<span
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line value-string css-dev-only-do-not-override-2i2tap"
>
sample-flask
</span>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text only-btn"
type="button"
>
<span>
Only
</span>
</button>
<button
class="ant-btn css-dev-only-do-not-override-2i2tap ant-btn-text toggle-btn"
type="button"
>
<span>
Toggle
</span>
</button>
</div>
</div>
</section>
</div>
<div
class="checkbox-filter"
>
<section
class="filter-header-checkbox"
>
<section
class="left-action"
>
<svg
class="lucide lucide-chevron-right"
cursor="pointer"
fill="none"
height="13"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="13"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m9 18 6-6-6-6"
/>
</svg>
<span
class="ant-typography title css-dev-only-do-not-override-2i2tap"
>
Service Name
</span>
</section>
<section
class="right-action"
/>
</section>
</div>
</section>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,30 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FiltersType } from '../types';
export const QuickFiltersConfig = [
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Service Name',
attributeKey: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
];

View File

@@ -40,4 +40,5 @@ export enum QuickFiltersSource {
INFRA_MONITORING = 'infra-monitoring',
TRACES_EXPLORER = 'traces-explorer',
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
}

View File

@@ -27,4 +27,5 @@ export enum LOCALSTORAGE {
CELERY_OVERVIEW_COLUMNS = 'CELERY_OVERVIEW_COLUMNS',
DONT_SHOW_SLOW_API_WARNING = 'DONT_SHOW_SLOW_API_WARNING',
METRICS_LIST_OPTIONS = 'METRICS_LIST_OPTIONS',
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
}

View File

@@ -398,6 +398,23 @@ export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
],
};
export enum OperatorConfigKeys {
'EXCEPTIONS' = 'EXCEPTIONS',
}
export const OPERATORS_CONFIG = {
[OperatorConfigKeys.EXCEPTIONS]: [
OPERATORS['='],
OPERATORS['!='],
OPERATORS.IN,
OPERATORS.NIN,
OPERATORS.EXISTS,
OPERATORS.NOT_EXISTS,
OPERATORS.CONTAINS,
OPERATORS.NOT_CONTAINS,
],
};
export const HAVING_OPERATORS: string[] = [
OPERATORS['='],
OPERATORS['!='],

View File

@@ -16,3 +16,51 @@ export const OperatorConversions: Array<{
traceValue: 'NotIn',
},
];
// mapping from qb to exceptions
export const CompositeQueryOperatorsConfig: Array<{
label: string;
metricValue: string;
traceValue: OperatorValues;
}> = [
{
label: 'in',
metricValue: '=~',
traceValue: 'In',
},
{
label: 'nin',
metricValue: '!~',
traceValue: 'NotIn',
},
{
label: '=',
metricValue: '=',
traceValue: 'Equals',
},
{
label: '!=',
metricValue: '!=',
traceValue: 'NotEquals',
},
{
label: 'exists',
metricValue: '=~',
traceValue: 'Exists',
},
{
label: 'nexists',
metricValue: '!~',
traceValue: 'NotExists',
},
{
label: 'contains',
metricValue: '=~',
traceValue: 'Contains',
},
{
label: 'ncontains',
metricValue: '!~',
traceValue: 'NotContains',
},
];

View File

@@ -18,16 +18,17 @@ import getErrorCounts from 'api/errors/getErrorCounts';
import { ResizeTable } from 'components/ResizeTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useNotifications } from 'hooks/useNotifications';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { convertRawQueriesToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { convertCompositeQueryToTraceSelectedTags } from 'hooks/useResourceAttribute/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
@@ -109,10 +110,11 @@ function AllErrors(): JSX.Element {
);
const { queries } = useResourceAttribute();
const compositeData = useGetCompositeQueryParam();
const [{ isLoading, data }, errorCountResponse] = useQueries([
{
queryKey: ['getAllErrors', updatedPath, maxTime, minTime, queries],
queryKey: ['getAllErrors', updatedPath, maxTime, minTime, compositeData],
queryFn: (): Promise<SuccessResponse<PayloadProps> | ErrorResponse> =>
getAll({
end: maxTime,
@@ -123,7 +125,9 @@ function AllErrors(): JSX.Element {
orderParam: getUpdatedParams,
exceptionType: getUpdatedExceptionType,
serviceName: getUpdatedServiceName,
tags: convertRawQueriesToTraceSelectedTags(queries),
tags: convertCompositeQueryToTraceSelectedTags(
compositeData?.builder.queryData?.[0]?.filters.items,
),
}),
enabled: !loading,
},
@@ -134,7 +138,7 @@ function AllErrors(): JSX.Element {
minTime,
getUpdatedExceptionType,
getUpdatedServiceName,
queries,
compositeData,
],
queryFn: (): Promise<ErrorResponse | SuccessResponse<number>> =>
getErrorCounts({
@@ -142,7 +146,9 @@ function AllErrors(): JSX.Element {
start: minTime,
exceptionType: getUpdatedExceptionType,
serviceName: getUpdatedServiceName,
tags: convertRawQueriesToTraceSelectedTags(queries),
tags: convertCompositeQueryToTraceSelectedTags(
compositeData?.builder.queryData?.[0]?.filters.items,
),
}),
enabled: !loading,
},
@@ -429,12 +435,8 @@ function AllErrors(): JSX.Element {
[pathname],
);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (
!logEventCalledRef.current &&
!isUndefined(errorCountResponse.data?.payload)
) {
if (!isUndefined(errorCountResponse.data?.payload)) {
const selectedEnvironments = queries.find(
(val) => val.tagKey === 'resource_deployment_environment',
)?.tagValue;
@@ -442,9 +444,12 @@ function AllErrors(): JSX.Element {
logEvent('Exception: List page visited', {
numberOfExceptions: errorCountResponse?.data?.payload,
selectedEnvironments,
resourceAttributeUsed: !!queries?.length,
resourceAttributeUsed: !!compositeData?.builder.queryData?.[0]?.filters
.items?.length,
tags: convertCompositeQueryToTraceSelectedTags(
compositeData?.builder.queryData?.[0]?.filters.items,
),
});
logEventCalledRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [errorCountResponse.data?.payload]);

View File

@@ -0,0 +1,114 @@
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import { Provider, useSelector } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
import AllErrors from '../index';
import {
INIT_URL_WITH_COMMON_QUERY,
MOCK_ERROR_LIST,
TAG_FROM_QUERY,
} from './constants';
jest.mock('hooks/useResourceAttribute', () =>
jest.fn(() => ({
queries: [],
})),
);
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(),
}));
function Exceptions({ initUrl }: { initUrl?: string[] }): JSX.Element {
return (
<MemoryRouter initialEntries={initUrl ?? ['/exceptions']}>
<TimezoneProvider>
<Provider store={store}>
<MockQueryClientProvider>
<AllErrors />
</MockQueryClientProvider>
</Provider>
</TimezoneProvider>
</MemoryRouter>
);
}
Exceptions.defaultProps = {
initUrl: ['/exceptions'],
};
const BASE_URL = ENVIRONMENT.baseURL;
const listErrorsURL = `${BASE_URL}/api/v1/listErrors`;
const countErrorsURL = `${BASE_URL}/api/v1/countErrors`;
const postListErrorsSpy = jest.fn();
describe('Exceptions - All Errors', () => {
beforeEach(() => {
(useSelector as jest.Mock).mockReturnValue({
maxTime: 1000,
minTime: 0,
loading: false,
});
server.use(
rest.post(listErrorsURL, async (req, res, ctx) => {
const body = await req.json();
postListErrorsSpy(body);
return res(ctx.status(200), ctx.json(MOCK_ERROR_LIST));
}),
);
server.use(
rest.post(countErrorsURL, (req, res, ctx) =>
res(ctx.status(200), ctx.json(540)),
),
);
});
it('renders correctly with default props', async () => {
render(<Exceptions />);
const item = await screen.findByText(/redis timeout/i);
expect(item).toBeInTheDocument();
});
it('should sort Error Message appropriately', async () => {
render(<Exceptions />);
await screen.findByText(/redis timeout/i);
const caretIconUp = screen.getAllByLabelText('caret-up')[0];
const caretIconDown = screen.getAllByLabelText('caret-down')[0];
// sort by ascending
expect(caretIconUp.className).not.toContain('active');
fireEvent.click(caretIconUp);
expect(caretIconUp.className).toContain('active');
let queryParams = new URLSearchParams(window.location.search);
expect(queryParams.get('order')).toBe('ascending');
expect(queryParams.get('orderParam')).toBe('exceptionType');
// sort by descending
expect(caretIconDown.className).not.toContain('active');
fireEvent.click(caretIconDown);
expect(caretIconDown.className).toContain('active');
queryParams = new URLSearchParams(window.location.search);
expect(queryParams.get('order')).toBe('descending');
});
it('should call useQueries with exact composite query object', async () => {
render(<Exceptions initUrl={[INIT_URL_WITH_COMMON_QUERY]} />);
await screen.findByText(/redis timeout/i);
expect(postListErrorsSpy).toHaveBeenCalledWith(
expect.objectContaining({
tags: TAG_FROM_QUERY,
}),
);
});
});

View File

@@ -0,0 +1,94 @@
export const MOCK_USE_QUERIES_DATA = [
{
isLoading: false,
isError: false,
error: null,
data: {
statusCode: 200,
payload: [
{
exceptionType: '*errors.errorString',
exceptionMessage: 'redis timeout',
exceptionCount: 2510,
lastSeen: '2025-04-14T18:27:57.797616374Z',
firstSeen: '2025-04-14T17:58:00.262775497Z',
serviceName: 'redis-manual',
groupID: '511b9c91a92b9c5166ecb77235f5743b',
},
],
},
},
{
status: 'success',
isLoading: false,
isSuccess: true,
isError: false,
isIdle: false,
data: {
statusCode: 200,
error: null,
payload: 525,
},
dataUpdatedAt: 1744661020341,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isRefetching: false,
isLoadingError: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isStale: true,
},
];
export const INIT_URL_WITH_COMMON_QUERY =
'/exceptions?compositeQuery=%257B%2522queryType%2522%253A%2522builder%2522%252C%2522builder%2522%253A%257B%2522queryData%2522%253A%255B%257B%2522dataSource%2522%253A%2522traces%2522%252C%2522queryName%2522%253A%2522A%2522%252C%2522aggregateOperator%2522%253A%2522noop%2522%252C%2522aggregateAttribute%2522%253A%257B%2522id%2522%253A%2522----resource--false%2522%252C%2522dataType%2522%253A%2522%2522%252C%2522key%2522%253A%2522%2522%252C%2522isColumn%2522%253Afalse%252C%2522type%2522%253A%2522resource%2522%252C%2522isJSON%2522%253Afalse%257D%252C%2522timeAggregation%2522%253A%2522rate%2522%252C%2522spaceAggregation%2522%253A%2522sum%2522%252C%2522functions%2522%253A%255B%255D%252C%2522filters%2522%253A%257B%2522items%2522%253A%255B%257B%2522id%2522%253A%2522db118ac7-9313-4adb-963f-f31b5b32c496%2522%252C%2522op%2522%253A%2522in%2522%252C%2522key%2522%253A%257B%2522key%2522%253A%2522deployment.environment%2522%252C%2522dataType%2522%253A%2522string%2522%252C%2522type%2522%253A%2522resource%2522%252C%2522isColumn%2522%253Afalse%252C%2522isJSON%2522%253Afalse%257D%252C%2522value%2522%253A%2522mq-kafka%2522%257D%255D%252C%2522op%2522%253A%2522AND%2522%257D%252C%2522expression%2522%253A%2522A%2522%252C%2522disabled%2522%253Afalse%252C%2522stepInterval%2522%253A60%252C%2522having%2522%253A%255B%255D%252C%2522limit%2522%253Anull%252C%2522orderBy%2522%253A%255B%255D%252C%2522groupBy%2522%253A%255B%255D%252C%2522legend%2522%253A%2522%2522%252C%2522reduceTo%2522%253A%2522avg%2522%257D%255D%252C%2522queryFormulas%2522%253A%255B%255D%257D%252C%2522promql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522query%2522%253A%2522%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%257D%255D%252C%2522clickhouse_sql%2522%253A%255B%257B%2522name%2522%253A%2522A%2522%252C%2522legend%2522%253A%2522%2522%252C%2522disabled%2522%253Afalse%252C%2522query%2522%253A%2522%2522%257D%255D%252C%2522id%2522%253A%2522dd576d04-0822-476d-b0c2-807a7af2e5e7%2522%257D';
export const extractCompositeQueryObject = (
url: string,
): Record<string, unknown> | null => {
try {
const urlObj = new URL(`http://dummy-base${url}`); // Add dummy base to parse relative URL
const encodedParam = urlObj.searchParams.get('compositeQuery');
if (!encodedParam) return null;
// Decode twice
const firstDecode = decodeURIComponent(encodedParam);
const secondDecode = decodeURIComponent(firstDecode);
// Parse JSON
return JSON.parse(secondDecode);
} catch (err) {
console.error('Failed to extract compositeQuery:', err);
return null;
}
};
export const TAG_FROM_QUERY = [
{
BoolValues: [],
Key: 'deployment.environment',
NumberValues: [],
Operator: 'In',
StringValues: ['mq-kafka'],
TagType: 'ResourceAttribute',
},
];
export const MOCK_ERROR_LIST = [
{
exceptionType: '*errors.errorString',
exceptionMessage: 'redis timeout',
exceptionCount: 2510,
lastSeen: '2025-04-14T18:27:57.797616374Z',
firstSeen: '2025-04-14T17:58:00.262775497Z',
serviceName: 'redis-manual',
groupID: '511b9c91a92b9c5166ecb77235f5743b',
},
];

View File

@@ -339,6 +339,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const isApiMonitoringView = (): boolean => routeKey === 'API_MONITORING';
const isExceptionsView = (): boolean => routeKey === 'ALL_ERROR';
const isTracesView = (): boolean =>
routeKey === 'TRACES_EXPLORER' || routeKey === 'TRACES_SAVE_VIEWS';
@@ -661,7 +663,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
isMessagingQueues() ||
isCloudIntegrationPage() ||
isInfraMonitoring() ||
isApiMonitoringView()
isApiMonitoringView() ||
isExceptionsView()
? 0
: '0 1rem',

View File

@@ -13,6 +13,7 @@ import AddToQueryHOC, {
import { ResizeTable } from 'components/ResizeTable';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { FontSize, OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import history from 'lib/history';
@@ -34,9 +35,6 @@ import FieldRenderer from './FieldRenderer';
import { TableViewActions } from './TableView/TableViewActions';
import { filterKeyForField, findKeyPath, flattenObject } from './utils';
// Fields which should be restricted from adding it to query
const RESTRICTED_FIELDS = ['timestamp'];
interface TableViewProps {
logData: ILog;
fieldSearchInput: string;
@@ -249,7 +247,7 @@ function TableView({
}
const fieldFilterKey = filterKeyForField(field);
if (!RESTRICTED_FIELDS.includes(fieldFilterKey)) {
if (!RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey)) {
return (
<AddToQueryHOC
fieldKey={fieldFilterKey}

View File

@@ -9,6 +9,7 @@ import CopyClipboardHOC from 'components/Logs/CopyClipboardHOC';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import dompurify from 'dompurify';
import { isEmpty } from 'lodash-es';
import { ArrowDownToDot, ArrowUpFromDot, Ellipsis } from 'lucide-react';
@@ -142,7 +143,7 @@ export function TableViewActions(
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
{renderFieldContent()}
</CopyClipboardHOC>
{!isListViewPanel && (
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
<span className="action-btn">
<Tooltip title="Filter for value">
<Button

View File

@@ -0,0 +1,130 @@
import { render, screen } from '@testing-library/react';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { TableViewActions } from '../TableViewActions';
// Mock the components and hooks
jest.mock('components/Logs/CopyClipboardHOC', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div className="CopyClipboardHOC">{children}</div>
),
}));
jest.mock('providers/Timezone', () => ({
useTimezone: (): {
formatTimezoneAdjustedTimestamp: (timestamp: string) => string;
} => ({
formatTimezoneAdjustedTimestamp: (timestamp: string): string => timestamp,
}),
}));
jest.mock('react-router-dom', () => ({
useLocation: (): {
pathname: string;
search: string;
hash: string;
state: null;
} => ({
pathname: '/test',
search: '',
hash: '',
state: null,
}),
}));
describe('TableViewActions', () => {
const TEST_VALUE = 'test value';
const ACTION_BUTTON_TEST_ID = '.action-btn';
const defaultProps = {
fieldData: {
field: 'test-field',
value: TEST_VALUE,
},
record: {
key: 'test-key',
field: 'test-field',
value: TEST_VALUE,
},
isListViewPanel: false,
isfilterInLoading: false,
isfilterOutLoading: false,
onClickHandler: jest.fn(),
onGroupByAttribute: jest.fn(),
};
it('should render without crashing', () => {
render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
/>,
);
expect(screen.getByText(TEST_VALUE)).toBeInTheDocument();
});
it('should not render action buttons for restricted fields', () => {
RESTRICTED_SELECTED_FIELDS.forEach((field) => {
const { container } = render(
<TableViewActions
fieldData={{
...defaultProps.fieldData,
field,
}}
record={{
...defaultProps.record,
field,
}}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
/>,
);
// Verify that action buttons are not rendered for restricted fields
expect(
container.querySelector(ACTION_BUTTON_TEST_ID),
).not.toBeInTheDocument();
});
});
it('should render action buttons for non-restricted fields', () => {
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel={defaultProps.isListViewPanel}
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
/>,
);
// Verify that action buttons are rendered for non-restricted fields
expect(container.querySelector(ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should not render action buttons in list view panel', () => {
const { container } = render(
<TableViewActions
fieldData={defaultProps.fieldData}
record={defaultProps.record}
isListViewPanel
isfilterInLoading={defaultProps.isfilterInLoading}
isfilterOutLoading={defaultProps.isfilterOutLoading}
onClickHandler={defaultProps.onClickHandler}
onGroupByAttribute={defaultProps.onGroupByAttribute}
/>,
);
// Verify that action buttons are not rendered in list view panel
expect(
container.querySelector(ACTION_BUTTON_TEST_ID),
).not.toBeInTheDocument();
});
});

View File

@@ -453,7 +453,7 @@ export const Query = memo(function Query({
</Col>
)}
<Col flex="1" className="qb-search-container">
{query.dataSource === DataSource.LOGS ? (
{[DataSource.LOGS, DataSource.TRACES].includes(query.dataSource) ? (
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}

View File

@@ -56,7 +56,6 @@ import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import OptionRenderer from './OptionRenderer';
import OptionRendererForLogs from './OptionRendererForLogs';
import SpanScopeSelector from './SpanScopeSelector';
import { StyledCheckOutlined, TypographyText } from './style';
import {
convertExampleQueriesToOptions,
@@ -84,11 +83,6 @@ function QueryBuilderSearch({
pathname,
]);
const isTracesExplorerPage = useMemo(
() => pathname === ROUTES.TRACES_EXPLORER,
[pathname],
);
const [isEditingTag, setIsEditingTag] = useState(false);
const {
@@ -489,7 +483,6 @@ function QueryBuilderSearch({
</Select.Option>
))}
</Select>
{isTracesExplorerPage && <SpanScopeSelector queryName={query.queryName} />}
</div>
);
}

View File

@@ -2,6 +2,7 @@
import './QueryBuilderSearchV2.styles.scss';
import { Typography } from 'antd';
import cx from 'classnames';
import {
ArrowDown,
ArrowUp,
@@ -25,6 +26,7 @@ interface ICustomDropdownProps {
exampleQueries: TagFilter[];
onChange: (value: TagFilter) => void;
currentFilterItem?: ITag;
isLogsDataSource: boolean;
}
export default function QueryBuilderSearchDropdown(
@@ -38,11 +40,14 @@ export default function QueryBuilderSearchDropdown(
exampleQueries,
options,
onChange,
isLogsDataSource,
} = props;
const userOs = getUserOperatingSystem();
return (
<>
<div className="content">
<div
className={cx('content', { 'non-logs-data-source': !isLogsDataSource })}
>
{!currentFilterItem?.key ? (
<div className="suggested-filters">Suggested Filters</div>
) : !currentFilterItem?.op ? (

View File

@@ -11,6 +11,11 @@
.rc-virtual-list-holder {
height: 115px;
}
&.non-logs-data-source {
.rc-virtual-list-holder {
height: 256px;
}
}
}
}

View File

@@ -5,6 +5,7 @@ import { Select, Spin, Tag, Tooltip } from 'antd';
import cx from 'classnames';
import {
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
OperatorConfigKeys,
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
@@ -62,7 +63,9 @@ import {
getTagToken,
isInNInOperator,
} from '../QueryBuilderSearch/utils';
import { filterByOperatorConfig } from '../utils';
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
import SpanScopeSelector from './SpanScopeSelector';
import Suggestions from './Suggestions';
export interface ITag {
@@ -88,6 +91,7 @@ interface QueryBuilderSearchV2Props {
className?: string;
suffixIcon?: React.ReactNode;
hardcodedAttributeKeys?: BaseAutocompleteData[];
operatorConfigKey?: OperatorConfigKeys;
}
export interface Option {
@@ -121,6 +125,7 @@ function QueryBuilderSearchV2(
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
operatorConfigKey,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -290,7 +295,8 @@ function QueryBuilderSearchV2(
if (
isObject(parsedValue) &&
parsedValue?.key &&
parsedValue?.key?.split(' ').length > 1
parsedValue?.key?.split(' ').length > 1 &&
isLogsDataSource
) {
setTags((prev) => [
...prev,
@@ -405,7 +411,13 @@ function QueryBuilderSearchV2(
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
[
currentFilterItem?.key,
currentFilterItem?.op,
currentState,
isLogsDataSource,
searchValue,
],
);
const handleSearch = useCallback((value: string) => {
@@ -689,12 +701,29 @@ function QueryBuilderSearchV2(
})),
);
} else {
setDropdownOptions(
data?.payload?.attributeKeys?.map((key) => ({
setDropdownOptions([
// Add user typed option if it doesn't exist in the payload
...(tagKey.trim().length > 0 &&
!data?.payload?.attributeKeys?.some((val) => val.key === tagKey)
? [
{
label: tagKey,
value: {
key: tagKey,
dataType: DataTypes.EMPTY,
type: '',
isColumn: false,
isJSON: false,
},
},
]
: []),
// Map existing attribute keys from payload
...(data?.payload?.attributeKeys?.map((key) => ({
label: key.key,
value: key,
})) || [],
);
})) || []),
]);
}
}
if (currentState === DropdownState.OPERATOR) {
@@ -717,15 +746,11 @@ function QueryBuilderSearchV2(
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
operatorOptions = [{ label: '', value: '' }, ...operatorOptions];
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
operatorOptions = [{ label: '', value: '' }, ...operatorOptions];
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
@@ -739,9 +764,12 @@ function QueryBuilderSearchV2(
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
operatorOptions = [{ label: '', value: '' }, ...operatorOptions];
setDropdownOptions(operatorOptions);
}
const filterOperatorOptions = filterByOperatorConfig(
operatorOptions,
operatorConfigKey,
);
setDropdownOptions([{ label: '', value: '' }, ...filterOperatorOptions]);
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
@@ -774,6 +802,7 @@ function QueryBuilderSearchV2(
isLogsDataSource,
searchValue,
suggestionsData?.payload?.attributes,
operatorConfigKey,
]);
// keep the query in sync with the selected tags in logs explorer page
@@ -907,6 +936,11 @@ function QueryBuilderSearchV2(
);
};
const isTracesDataSource = useMemo(
() => query.dataSource === DataSource.TRACES,
[query.dataSource],
);
return (
<div className="query-builder-search-v2">
<Select
@@ -964,6 +998,7 @@ function QueryBuilderSearchV2(
exampleQueries={suggestionsData?.payload?.example_queries || []}
tags={tags}
currentFilterItem={currentFilterItem}
isLogsDataSource={isLogsDataSource}
/>
)}
>
@@ -990,6 +1025,7 @@ function QueryBuilderSearchV2(
);
})}
</Select>
{isTracesDataSource && <SpanScopeSelector queryName={query.queryName} />}
</div>
);
}
@@ -1000,6 +1036,7 @@ QueryBuilderSearchV2.defaultProps = {
suffixIcon: null,
whereClauseConfig: {},
hardcodedAttributeKeys: undefined,
operatorConfigKey: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -120,6 +120,7 @@ function SpanScopeSelector({ queryName }: SpanScopeSelectorProps): JSX.Element {
<Select
value={selectedScope}
className="span-scope-selector"
data-testid="span-scope-selector"
onChange={handleScopeChange}
options={SELECT_OPTIONS}
/>

View File

@@ -0,0 +1,196 @@
/* eslint-disable react/jsx-props-no-spreading */
import {
act,
fireEvent,
render,
RenderResult,
screen,
} from '@testing-library/react';
import {
initialQueriesMap,
initialQueryBuilderFormValues,
} from 'constants/queryBuilder';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { QueryClient, QueryClientProvider } from 'react-query';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import QueryBuilderSearchV2 from '../QueryBuilderSearchV2';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
describe('Span scope selector', () => {
it('should render span scope selector when data source is TRACES', () => {
const { getByTestId } = render(
<QueryClientProvider client={queryClient}>
<QueryBuilderSearchV2
query={{
...initialQueryBuilderFormValues,
dataSource: DataSource.TRACES,
}}
onChange={jest.fn()}
/>
</QueryClientProvider>,
);
expect(getByTestId('span-scope-selector')).toBeInTheDocument();
});
it('should not render span scope selector for non-TRACES data sources', () => {
const { queryByTestId } = render(
<QueryClientProvider client={queryClient}>
<QueryBuilderSearchV2
query={{
...initialQueryBuilderFormValues,
dataSource: DataSource.METRICS,
}}
onChange={jest.fn()}
/>
</QueryClientProvider>,
);
expect(queryByTestId('span-scope-selector')).not.toBeInTheDocument();
});
});
const mockOnChange = jest.fn();
const mockHandleRunQuery = jest.fn();
const defaultProps = {
query: {
...initialQueriesMap.traces.builder.queryData[0],
dataSource: DataSource.TRACES,
queryName: 'traces_query',
},
onChange: mockOnChange,
};
const renderWithContext = (props = {}): RenderResult => {
const mergedProps = { ...defaultProps, ...props };
return render(
<QueryClientProvider client={queryClient}>
<QueryBuilderContext.Provider
value={
{
currentQuery: initialQueriesMap.traces,
handleRunQuery: mockHandleRunQuery,
} as any
}
>
<QueryBuilderSearchV2 {...mergedProps} />
</QueryBuilderContext.Provider>
</QueryClientProvider>,
);
};
const mockAggregateKeysData = {
payload: {
attributeKeys: [
{
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'http.status',
dataType: DataTypes.String,
type: 'tag',
isColumn: false,
isJSON: false,
id: 'http.status--string--tag--false',
},
],
},
};
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(() => ({
data: mockAggregateKeysData,
isFetching: false,
})),
}));
const mockAggregateValuesData = {
payload: {
stringAttributeValues: ['200', '404', '500'],
numberAttributeValues: [200, 404, 500],
},
};
jest.mock('hooks/queryBuilder/useGetAggregateValues', () => ({
useGetAggregateValues: jest.fn(() => ({
data: mockAggregateValuesData,
isFetching: false,
})),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
describe('Suggestion Key -> Operator -> Value Flow', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should complete full flow from key selection to value', async () => {
const { container } = renderWithContext();
// Get the combobox input specifically
const combobox = container.querySelector(
'.query-builder-search-v2 .ant-select-selection-search-input',
) as HTMLInputElement;
// 1. Focus and type to trigger key suggestions
await act(async () => {
fireEvent.focus(combobox);
fireEvent.change(combobox, { target: { value: 'http.' } });
});
// Wait for dropdown to appear
await screen.findByRole('listbox');
// 2. Select a key from suggestions
const statusOption = await screen.findByText('http.status');
await act(async () => {
fireEvent.click(statusOption);
});
// Should show operator suggestions
expect(screen.getByText('=')).toBeInTheDocument();
expect(screen.getByText('!=')).toBeInTheDocument();
// 3. Select an operator
const equalsOption = screen.getByText('=');
await act(async () => {
fireEvent.click(equalsOption);
});
// Should show value suggestions
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('404')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
// 4. Select a value
const valueOption = screen.getByText('200');
await act(async () => {
fireEvent.click(valueOption);
});
// Verify final filter
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.status' }),
op: '=',
value: '200',
}),
]),
}),
);
});
});

View File

@@ -0,0 +1,165 @@
import {
fireEvent,
render,
RenderResult,
screen,
} from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import SpanScopeSelector from '../SpanScopeSelector';
const mockRedirectWithQueryBuilderData = jest.fn();
// Helper to create filter items
const createSpanScopeFilter = (key: string): TagFilterItem => ({
id: 'span-filter',
key: {
key,
type: 'spanSearchScope',
},
op: '=',
value: 'true',
});
const defaultQuery = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
queryName: 'A',
},
],
},
};
// Helper to create query with filters
const createQueryWithFilters = (filters: TagFilterItem[]): Query => ({
...defaultQuery,
builder: {
...defaultQuery.builder,
queryData: [
{
...defaultQuery.builder.queryData[0],
filters: {
items: filters,
op: 'AND',
},
},
],
},
});
const renderWithContext = (
queryName = 'A',
initialQuery = defaultQuery,
): RenderResult =>
render(
<QueryBuilderContext.Provider
value={
{
currentQuery: initialQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
>
<SpanScopeSelector queryName={queryName} />
</QueryBuilderContext.Provider>,
);
describe('SpanScopeSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default ALL_SPANS selected', () => {
renderWithContext();
expect(screen.getByText('All Spans')).toBeInTheDocument();
});
describe('when selecting different options', () => {
const selectOption = (optionText: string): void => {
const selector = screen.getByRole('combobox');
fireEvent.mouseDown(selector);
const option = screen.getByText(optionText);
fireEvent.click(option);
};
const assertFilterAdded = (
updatedQuery: Query,
expectedKey: string,
): void => {
const filters = updatedQuery.builder.queryData[0].filters.items;
expect(filters).toContainEqual(
expect.objectContaining({
key: expect.objectContaining({
key: expectedKey,
type: 'spanSearchScope',
}),
op: '=',
value: 'true',
}),
);
};
it('should remove span scope filters when selecting ALL_SPANS', () => {
const queryWithSpanScope = createQueryWithFilters([
createSpanScopeFilter('isRoot'),
]);
renderWithContext('A', queryWithSpanScope);
selectOption('All Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
const filters = updatedQuery.builder.queryData[0].filters.items;
expect(filters).not.toContainEqual(
expect.objectContaining({
key: expect.objectContaining({ type: 'spanSearchScope' }),
}),
);
});
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
renderWithContext();
await selectOption('Root Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
assertFilterAdded(
mockRedirectWithQueryBuilderData.mock.calls[0][0],
'isRoot',
);
});
it('should add isEntryPoint filter when selecting ENTRYPOINT_SPANS', () => {
renderWithContext();
selectOption('Entrypoint Spans');
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
assertFilterAdded(
mockRedirectWithQueryBuilderData.mock.calls[0][0],
'isEntryPoint',
);
});
});
describe('when initializing with existing filters', () => {
it.each([
['Root Spans', 'isRoot'],
['Entrypoint Spans', 'isEntryPoint'],
])(
'should initialize with %s selected when %s filter exists',
async (expectedText, filterKey) => {
const queryWithFilter = createQueryWithFilters([
createSpanScopeFilter(filterKey),
]);
renderWithContext('A', queryWithFilter);
expect(await screen.findByText(expectedText)).toBeInTheDocument();
},
);
});
});

View File

@@ -1,4 +1,5 @@
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
import uniqWith from 'lodash-es/unionWith';
@@ -110,3 +111,13 @@ export const transformKeyValuesToAttributeValuesMap = (
},
]),
);
export const filterByOperatorConfig = (
options: IOption[],
key?: OperatorConfigKeys,
): IOption[] => {
if (!key || !OPERATORS_CONFIG[key]) return options;
return options.filter((option) =>
OPERATORS_CONFIG[key].includes(option.label),
);
};

View File

@@ -0,0 +1,17 @@
.resourceAttributesFilter-container-v2 {
margin: 8px;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--bg-slate-400) !important;
background-color: var(--bg-ink-300) !important;
input {
font-size: 12px;
}
.ant-tag .ant-typography {
font-size: 12px;
}
}
}

View File

@@ -0,0 +1,60 @@
import './ResourceAttributesFilter.styles.scss';
import { initialQueriesMap, OperatorConfigKeys } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useCallback } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
function ResourceAttributesFilter(): JSX.Element | null {
const { currentQuery } = useQueryBuilder();
const query = currentQuery?.builder?.queryData[0] || null;
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query,
entityVersion: '',
});
// initialise tab with default query.
useShareBuilderUrl({
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...initialQueriesMap.traces.builder.queryData[0].aggregateAttribute,
type: 'resource',
},
queryName: '',
},
],
},
});
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleChangeQueryData('filters', value);
},
[handleChangeQueryData],
);
return (
<div className="resourceAttributesFilter-container-v2">
<QueryBuilderSearchV2
query={query}
onChange={handleChangeTagFilters}
operatorConfigKey={OperatorConfigKeys.EXCEPTIONS}
/>
</div>
);
}
export default ResourceAttributesFilter;

View File

@@ -230,6 +230,7 @@ export const routesToSkip = [
ROUTES.CHANNELS_NEW,
ROUTES.CHANNELS_EDIT,
ROUTES.WORKSPACE_ACCESS_RESTRICTED,
ROUTES.ALL_ERROR,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -0,0 +1,39 @@
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
.ant-btn-default {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
&.active-tab {
background-color: var(--bg-slate-400);
}
}
.copy-span-btn {
border-color: var(--bg-slate-400) !important;
}
}
.lightMode {
.span-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-400);
.copy-span-btn {
border-color: var(--bg-vanilla-400) !important;
}
}
}

View File

@@ -0,0 +1,134 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
describe('SpanLineActionButtons', () => {
beforeEach(() => {
// Clear mock before each test
jest.clearAllMocks();
});
it('renders copy link button with correct icon', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the button is rendered
const copyButton = screen.getByRole('button');
expect(copyButton).toBeInTheDocument();
// Check if the link icon is rendered
const linkIcon = screen.getByRole('img', { hidden: true });
expect(linkIcon).toHaveClass('anticon anticon-link');
});
it('calls onSpanCopy when copy button is clicked', () => {
const mockOnSpanCopy = jest.fn();
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: mockOnSpanCopy,
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {
delete: jest.fn(),
set: jest.fn(),
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
};
const mockPathname = '/test-path';
const mockLocation = {
origin: 'http://localhost:3000',
};
// Mock window.location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Mock useCopySpanLink hook
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;
mockSetCopy(link);
},
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called with correct link
expect(mockSetCopy).toHaveBeenCalledWith(
'http://localhost:3000/test-path?spanId=test-span-id',
);
});
});

View File

@@ -0,0 +1,28 @@
import './SpanLineActionButtons.styles.scss';
import { LinkOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Span } from 'types/api/trace/getTraceV2';
export interface SpanLineActionButtonsProps {
span: Span;
}
export default function SpanLineActionButtons({
span,
}: SpanLineActionButtonsProps): JSX.Element {
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className="span-line-action-buttons">
<Tooltip title="Copy Span Link">
<Button
size="small"
icon={<LinkOutlined size={14} />}
onClick={onSpanCopy}
className="copy-span-btn"
/>
</Tooltip>
</div>
);
}

View File

@@ -9,7 +9,10 @@ import cx from 'classnames';
import { TableV3 } from 'components/TableV3/TableV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import SpanLineActionButtons from 'container/TraceWaterfall/SpanLineActionButtons';
import { IInterestedSpan } from 'container/TraceWaterfall/TraceWaterfall';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
AlertCircle,
@@ -25,6 +28,7 @@ import {
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
@@ -147,7 +151,7 @@ function SpanOverview({
);
}
function SpanDuration({
export function SpanDuration({
span,
traceMetadata,
setSelectedSpan,
@@ -166,20 +170,40 @@ function SpanDuration({
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
let color = generateColor(span.serviceName, themeColors.traceDetailColors);
if (span.hasError) {
color = `var(--bg-cherry-500)`;
}
const [hasActionButtons, setHasActionButtons] = useState(false);
const handleMouseEnter = (): void => {
setHasActionButtons(true);
};
const handleMouseLeave = (): void => {
setHasActionButtons(false);
};
return (
<div
className={cx(
'span-duration',
selectedSpan?.spanId === span.spanId ? 'interested-span' : '',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={(): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
}
safeNavigate({ search: urlQuery.toString() });
}}
>
<div
@@ -190,6 +214,7 @@ function SpanDuration({
backgroundColor: color,
}}
/>
{hasActionButtons && <SpanLineActionButtons span={span} />}
<Tooltip title={`${toFixed(time, 2)} ${timeUnitName}`}>
<Typography.Text
className="span-line-text"

View File

@@ -0,0 +1,131 @@
import { fireEvent, screen } from '@testing-library/react';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Mock the hooks
jest.mock('hooks/useSafeNavigate');
jest.mock('hooks/useUrlQuery');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1234567000,
endTime: 1234569000,
hasMissingSpans: false,
};
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock URL query hook
(useUrlQuery as jest.Mock).mockReturnValue({
set: mockUrlQuerySet,
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
// Mock safe navigate hook
(useSafeNavigate as jest.Mock).mockReturnValue({
safeNavigate: mockSafeNavigate,
});
});
it('updates URL and selected span when clicked', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText('1.16 ms');
fireEvent.click(spanElement);
// Verify setSelectedSpan was called with the correct span
expect(mockSetSelectedSpan).toHaveBeenCalledWith(mockSpan);
// Verify URL query was updated
expect(mockUrlQuerySet).toHaveBeenCalledWith('spanId', 'test-span-id');
// Verify navigation was triggered
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: 'spanId=test-span-id',
});
});
it('shows action buttons on hover', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
setSelectedSpan={mockSetSelectedSpan}
/>,
);
const spanElement = screen.getByText('1.16 ms');
// Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// Hover over the span
fireEvent.mouseEnter(spanElement);
// Action buttons should now be visible
expect(screen.getByRole('button')).toBeInTheDocument();
// Mouse leave should hide the buttons
fireEvent.mouseLeave(spanElement);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies interested-span class when span is selected', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
setSelectedSpan={mockSetSelectedSpan}
/>,
);
const spanElement = screen.getByText('1.16 ms').closest('.span-duration');
expect(spanElement).toHaveClass('interested-span');
});
});

View File

@@ -170,11 +170,7 @@ export const useOptions = (
(option, index, self) =>
index ===
self.findIndex(
(o) =>
// to remove duplicate & empty options from list
o.label === option.label &&
o.value === option.value &&
o.dataType?.toLowerCase() === option.dataType?.toLowerCase(), // handle case sensitivity
(o) => o.label === option.label && o.value === option.value, // to remove duplicate & empty options from list
) && option.value !== '',
) || []
).map((option) => {

View File

@@ -0,0 +1,42 @@
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Span } from 'types/api/trace/getTraceV2';
export const useCopySpanLink = (
span?: Span,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
if (!span) return;
event.preventDefault();
event.stopPropagation();
urlQuery.delete('spanId');
if (span.spanId) {
urlQuery.set('spanId', span?.spanId);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
notifications.success({
message: 'Copied to clipboard',
});
},
[span, urlQuery, pathname, setCopy, notifications],
);
return {
onSpanCopy,
};
};

View File

@@ -2,7 +2,10 @@ import {
getResourceAttributesTagKeys,
getResourceAttributesTagValues,
} from 'api/metrics/getResourceAttributes';
import { OperatorConversions } from 'constants/resourceAttributes';
import {
CompositeQueryOperatorsConfig,
OperatorConversions,
} from 'constants/resourceAttributes';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import {
@@ -49,6 +52,32 @@ export const convertOperatorLabelToTraceOperator = (
OperatorConversions.find((operator) => operator.label === label)
?.traceValue as OperatorValues;
export function convertOperatorLabelForExceptions(
label: string,
): OperatorValues {
return CompositeQueryOperatorsConfig.find(
(operator) => operator.label === label,
)?.traceValue as OperatorValues;
}
export function formatStringValuesForTrace(
val: TagFilterItem['value'] = [],
): string[] {
return !Array.isArray(val) ? [String(val)] : val;
}
export const convertCompositeQueryToTraceSelectedTags = (
filterItems: TagFilterItem[] = [],
): Tags[] =>
filterItems.map((item) => ({
Key: item?.key?.key,
Operator: convertOperatorLabelForExceptions(item.op),
StringValues: formatStringValuesForTrace(item?.value),
NumberValues: [],
BoolValues: [],
TagType: 'ResourceAttribute',
})) as Tags[];
export const convertRawQueriesToTraceSelectedTags = (
queries: IResourceAttribute[],
tagType = 'ResourceAttribute',

View File

@@ -0,0 +1,27 @@
.all-errors-page {
display: flex;
height: 100%;
.all-errors-quick-filter-section {
width: 0%;
flex-shrink: 0;
color: var(--bg-vanilla-100);
}
.all-errors-right-section {
padding: 0 10px;
}
.ant-tabs {
margin: 0 8px;
}
&.filter-visible {
.all-errors-quick-filter-section {
width: 260px;
}
.all-errors-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -1,18 +1,87 @@
import './AllErrors.styles.scss';
import { FilterOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
import ResourceAttributesFilter from 'container/ResourceAttributesFilter';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
import Toolbar from 'container/Toolbar/Toolbar';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import { routes } from './config';
import { ExceptionsQuickFiltersConfig } from './utils';
function AllErrors(): JSX.Element {
const { pathname } = useLocation();
const { handleRunQuery } = useQueryBuilder();
const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS,
);
if (!isNull(localStorageValue)) {
return localStorageValue === 'true';
}
return true;
});
const handleFilterVisibilityChange = (): void => {
setLocalStorageApi(
LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS,
String(!showFilters),
);
setShowFilters((prev) => !prev);
};
return (
<>
<ResourceAttributesFilter />
<RouteTab routes={routes} activeKey={pathname} history={history} />
</>
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={ExceptionsQuickFiltersConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
/>
</section>
)}
<section
className={cx(
'all-errors-right-section',
showFilters ? 'filter-visible' : '',
)}
>
<TypicalOverlayScrollbar>
<>
<Toolbar
showAutoRefresh={false}
leftActions={
!showFilters ? (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<FilterOutlined />
</Button>
</Tooltip>
) : undefined
}
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
/>
<ResourceAttributesFilterV2 />
<RouteTab routes={routes} activeKey={pathname} history={history} />
</>
</TypicalOverlayScrollbar>
</section>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import {
FiltersType,
IQuickFiltersConfig,
} from 'components/QuickFilters/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
export const ExceptionsQuickFiltersConfig: IQuickFiltersConfig[] = [
{
type: FiltersType.CHECKBOX,
title: 'Environment',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Service Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Hostname',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'host.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Cluster Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.cluster.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Deployment Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.deployment.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Namespace Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.namespace.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'K8s Pod Name',
dataSource: DataSource.TRACES,
attributeKey: {
key: 'k8s.pod.name',
dataType: DataTypes.String,
type: 'resource',
isColumn: false,
isJSON: false,
},
defaultOpen: false,
},
];

View File

@@ -1,6 +1,7 @@
import './LineClampedText.styles.scss';
import { Tooltip, TooltipProps } from 'antd';
import { isBoolean } from 'lodash-es';
import { useEffect, useRef, useState } from 'react';
function LineClampedText({
@@ -8,7 +9,7 @@ function LineClampedText({
lines,
tooltipProps,
}: {
text: string;
text: string | boolean;
lines?: number;
tooltipProps?: TooltipProps;
}): JSX.Element {
@@ -40,7 +41,7 @@ function LineClampedText({
WebkitLineClamp: lines,
}}
>
{text}
{isBoolean(text) ? String(text) : text}
</div>
);

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import LineClampedText from '../LineClampedText';
describe('LineClampedText', () => {
// Reset all mocks after each test
afterEach(() => {
jest.clearAllMocks();
});
it('renders string text correctly', () => {
const text = 'Test text';
render(<LineClampedText text={text} />);
expect(screen.getByText(text)).toBeInTheDocument();
});
it('renders empty string correctly', () => {
const { container } = render(<LineClampedText text="" />);
// For empty strings, we need to check that a div exists
// but it's harder to check for empty text directly with queries
expect(container.textContent).toBe('');
});
it('renders boolean text correctly - true', () => {
render(<LineClampedText text />);
expect(screen.getByText('true')).toBeInTheDocument();
});
it('renders boolean text correctly - false', () => {
render(<LineClampedText text={false} />);
expect(screen.getByText('false')).toBeInTheDocument();
});
it('applies line clamping with provided lines prop', () => {
const text = 'Test text with multiple lines';
const lines = 2;
render(<LineClampedText text={text} lines={lines} />);
// Verify the text is rendered correctly
expect(screen.getByText(text)).toBeInTheDocument();
// Verify the component received the correct props
expect((screen.getByText(text).style as any).WebkitLineClamp).toBe(
String(lines),
);
});
it('uses default line count of 1 when lines prop is not provided', () => {
const text = 'Test text';
render(<LineClampedText text={text} />);
// Verify the text is rendered correctly
expect(screen.getByText(text)).toBeInTheDocument();
// Verify the default props
expect(LineClampedText.defaultProps?.lines).toBe(1);
});
});

View File

@@ -6852,18 +6852,17 @@ copy-to-clipboard@^3.3.1, copy-to-clipboard@^3.3.3:
dependencies:
toggle-selection "^1.0.6"
copy-webpack-plugin@^8.1.0:
version "8.1.1"
resolved "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-8.1.1.tgz"
integrity sha512-rYM2uzRxrLRpcyPqGceRBDpxxUV8vcDqIKxAUKfcnFpcrPxT5+XvhTxv7XLjo5AvEJFPdAE3zCogG2JVahqgSQ==
copy-webpack-plugin@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a"
integrity sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==
dependencies:
fast-glob "^3.2.5"
glob-parent "^5.1.1"
globby "^11.0.3"
fast-glob "^3.2.11"
glob-parent "^6.0.1"
globby "^13.1.1"
normalize-path "^3.0.0"
p-limit "^3.1.0"
schema-utils "^3.0.0"
serialize-javascript "^5.0.1"
schema-utils "^4.0.0"
serialize-javascript "^6.0.0"
core-js-compat@^3.25.1:
version "3.30.1"
@@ -8740,7 +8739,7 @@ fast-diff@^1.1.2:
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-glob@^3.0.3:
fast-glob@^3.0.3, fast-glob@^3.2.11, fast-glob@^3.3.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
@@ -8751,7 +8750,7 @@ fast-glob@^3.0.3:
merge2 "^1.3.0"
micromatch "^4.0.8"
fast-glob@^3.2.5, fast-glob@^3.2.9:
fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz"
integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==
@@ -9307,13 +9306,20 @@ gl-preserve-state@^1.0.0:
resolved "https://registry.npmjs.org/gl-preserve-state/-/gl-preserve-state-1.0.0.tgz"
integrity sha512-zQZ25l3haD4hvgJZ6C9+s0ebdkW9y+7U2qxvGu1uWOJh8a4RU+jURIKEQhf8elIlFpMH6CrAY2tH0mYrRjet3Q==
glob-parent@^5.1.1, glob-parent@^5.1.2, glob-parent@~5.1.2:
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
glob-parent@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob-to-regexp@^0.4.1:
version "0.4.1"
resolved "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz"
@@ -9401,6 +9407,17 @@ globby@^11.0.3, globby@^11.1.0:
merge2 "^1.4.1"
slash "^3.0.0"
globby@^13.1.1:
version "13.2.2"
resolved "https://registry.yarnpkg.com/globby/-/globby-13.2.2.tgz#63b90b1bf68619c2135475cbd4e71e66aa090592"
integrity sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==
dependencies:
dir-glob "^3.0.1"
fast-glob "^3.3.0"
ignore "^5.2.4"
merge2 "^1.4.1"
slash "^4.0.0"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz"
@@ -10061,6 +10078,11 @@ ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
ignore@^5.2.4:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
image-size@~0.5.0:
version "0.5.5"
resolved "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz"
@@ -13401,7 +13423,7 @@ p-limit@^2.2.0:
dependencies:
p-try "^2.0.0"
p-limit@^3.0.2, p-limit@^3.1.0:
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
@@ -15845,17 +15867,10 @@ send@0.19.0:
range-parser "~1.2.1"
statuses "2.0.1"
serialize-javascript@^5.0.1:
version "5.0.1"
resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz"
integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
dependencies:
randombytes "^2.1.0"
serialize-javascript@^6.0.0, serialize-javascript@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz"
integrity sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==
serialize-javascript@6.0.2, serialize-javascript@^6.0.0, serialize-javascript@^6.0.1:
version "6.0.2"
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
dependencies:
randombytes "^2.1.0"
@@ -16012,6 +16027,11 @@ slash@^3.0.0:
resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
slash@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
slice-ansi@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz"

View File

@@ -5600,7 +5600,12 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
return
}
result = postprocess.TransformToTableForBuilderQueries(result, queryRangeParams)
result, err = postprocess.PostProcessResult(result, queryRangeParams)
if err != nil {
apiErrObj := &model.ApiError{Typ: model.ErrorBadData, Err: err}
RespondError(w, apiErrObj, errQuriesByName)
return
}
if !thirdPartyQueryRequest.ShowIp {
result = thirdPartyApi.FilterResponse(result)

View File

@@ -58,6 +58,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
builderQueries["endpoints"] = &v3.BuilderQuery{
QueryName: "endpoints",
Legend: "endpoints",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorCountDistinct,
@@ -91,11 +92,14 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
ReduceTo: v3.ReduceToOperatorAvg,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["lastseen"] = &v3.BuilderQuery{
QueryName: "lastseen",
Legend: "lastseen",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorMax,
@@ -127,11 +131,14 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
ReduceTo: v3.ReduceToOperatorAvg,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["rps"] = &v3.BuilderQuery{
QueryName: "rps",
Legend: "rps",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorRate,
@@ -163,18 +170,22 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
ReduceTo: v3.ReduceToOperatorAvg,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["error_rate"] = &v3.BuilderQuery{
QueryName: "error_rate",
builderQueries["error"] = &v3.BuilderQuery{
QueryName: "error",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorRate,
AggregateOperator: v3.AggregateOperatorCountDistinct,
AggregateAttribute: v3.AttributeKey{
Key: "",
Key: "span_id",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: true,
},
TimeAggregation: v3.TimeAggregationRate,
TimeAggregation: v3.TimeAggregationCountDistinct,
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
@@ -200,7 +211,7 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
},
}, thirdPartyApis.Filters),
},
Expression: "error_rate",
Expression: "error",
GroupBy: getGroupBy([]v3.AttributeKey{
{
Key: "net.peer.name",
@@ -208,11 +219,56 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
ReduceTo: v3.ReduceToOperatorAvg,
Disabled: true,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["total_span"] = &v3.BuilderQuery{
QueryName: "total_span",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorCountDistinct,
AggregateAttribute: v3.AttributeKey{
Key: "span_id",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: true,
},
TimeAggregation: v3.TimeAggregationCountDistinct,
SpaceAggregation: v3.SpaceAggregationSum,
Filters: &v3.FilterSet{
Operator: "AND",
Items: getFilterSet([]v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.url",
DataType: v3.AttributeKeyDataTypeString,
IsColumn: false,
Type: v3.AttributeKeyTypeTag,
},
Operator: v3.FilterOperatorExists,
Value: "",
},
}, thirdPartyApis.Filters),
},
Expression: "total_span",
GroupBy: getGroupBy([]v3.AttributeKey{
{
Key: "net.peer.name",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
Disabled: true,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["p99"] = &v3.BuilderQuery{
QueryName: "p99",
Legend: "p99",
DataSource: v3.DataSourceTraces,
StepInterval: defaultStepInterval,
AggregateOperator: v3.AggregateOperatorP99,
@@ -246,7 +302,18 @@ func BuildDomainList(thirdPartyApis *ThirdPartyApis) (*v3.QueryRangeParamsV3, er
Type: v3.AttributeKeyTypeTag,
},
}, thirdPartyApis.GroupBy),
ReduceTo: v3.ReduceToOperatorAvg,
ReduceTo: v3.ReduceToOperatorAvg,
ShiftBy: 0,
IsAnomaly: false,
}
builderQueries["error_rate"] = &v3.BuilderQuery{
QueryName: "error_rate",
Expression: "(error/total_span)*100",
Legend: "error_rate",
Disabled: false,
ShiftBy: 0,
IsAnomaly: false,
}
compositeQuery := &v3.CompositeQuery{

View File

@@ -26,6 +26,7 @@ var AggregateOperatorToSQLFunc = map[v3.AggregateOperator]string{
v3.AggregateOperatorMax: "max",
v3.AggregateOperatorMin: "min",
v3.AggregateOperatorSum: "sum",
v3.AggregateOperatorRate: "count",
v3.AggregateOperatorRateSum: "sum",
v3.AggregateOperatorRateAvg: "avg",
v3.AggregateOperatorRateMax: "max",

View File

@@ -282,7 +282,7 @@ func orderByAttributeKeyTags(panelType v3.PanelType, items []v3.OrderBy, tags []
return str
}
func generateAggregateClause(aggOp v3.AggregateOperator,
func generateAggregateClause(panelType v3.PanelType, start, end int64, aggOp v3.AggregateOperator,
aggKey string,
step int64,
timeFilter string,
@@ -296,18 +296,20 @@ func generateAggregateClause(aggOp v3.AggregateOperator,
"%s%s" +
"%s"
switch aggOp {
case v3.AggregateOperatorRate:
rate := float64(step)
op := fmt.Sprintf("count(%s)/%f", aggKey, rate)
query := fmt.Sprintf(queryTmpl, op, whereClause, groupBy, having, orderBy)
return query, nil
case
v3.AggregateOperatorRateSum,
v3.AggregateOperatorRateMax,
v3.AggregateOperatorRateAvg,
v3.AggregateOperatorRateMin:
v3.AggregateOperatorRateMin,
v3.AggregateOperatorRate:
rate := float64(step)
if panelType == v3.PanelTypeTable {
// if the panel type is table the denominator will be the total time range
duration := end - start
if duration >= 0 {
rate = float64(duration) / NANOSECOND
}
}
op := fmt.Sprintf("%s(%s)/%f", logsV3.AggregateOperatorToSQLFunc[aggOp], aggKey, rate)
query := fmt.Sprintf(queryTmpl, op, whereClause, groupBy, having, orderBy)
@@ -418,7 +420,7 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
filterSubQuery = filterSubQuery + " AND " + fmt.Sprintf("(%s) GLOBAL IN (", logsV3.GetSelectKeys(mq.AggregateOperator, mq.GroupBy)) + "#LIMIT_PLACEHOLDER)"
}
aggClause, err := generateAggregateClause(mq.AggregateOperator, aggregationKey, step, timeFilter, filterSubQuery, groupBy, having, orderBy)
aggClause, err := generateAggregateClause(panelType, logsStart, logsEnd, mq.AggregateOperator, aggregationKey, step, timeFilter, filterSubQuery, groupBy, having, orderBy)
if err != nil {
return "", err
}

View File

@@ -571,6 +571,9 @@ func Test_orderByAttributeKeyTags(t *testing.T) {
func Test_generateAggregateClause(t *testing.T) {
type args struct {
panelType v3.PanelType
start int64
end int64
op v3.AggregateOperator
aggKey string
step int64
@@ -590,6 +593,7 @@ func Test_generateAggregateClause(t *testing.T) {
name: "test rate",
args: args{
op: v3.AggregateOperatorRate,
panelType: v3.PanelTypeGraph,
aggKey: "test",
step: 60,
timeFilter: "(timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND (ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458)",
@@ -606,6 +610,7 @@ func Test_generateAggregateClause(t *testing.T) {
name: "test P10 with all args",
args: args{
op: v3.AggregateOperatorRate,
panelType: v3.PanelTypeGraph,
aggKey: "test",
step: 60,
timeFilter: "(timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND (ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458)",
@@ -618,10 +623,29 @@ func Test_generateAggregateClause(t *testing.T) {
"(ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458) AND attributes_string['service.name'] = 'test' group by `user_name` having value > 10 order by " +
"`user_name` desc",
},
{
name: "test rate for table panel",
args: args{
op: v3.AggregateOperatorRate,
panelType: v3.PanelTypeTable,
start: 1745315470000000000,
end: 1745319070000000000,
aggKey: "test",
step: 60,
timeFilter: "(timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND (ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458)",
whereClause: " AND attributes_string['service.name'] = 'test'",
groupBy: " group by `user_name`",
having: "",
orderBy: " order by `user_name` desc",
},
want: " count(test)/3600.000000 as value from signoz_logs.distributed_logs_v2 where (timestamp >= 1680066360726210000 AND timestamp <= 1680066458000000000) AND " +
"(ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458) AND attributes_string['service.name'] = 'test' " +
"group by `user_name` order by `user_name` desc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := generateAggregateClause(tt.args.op, tt.args.aggKey, tt.args.step, tt.args.timeFilter, tt.args.whereClause, tt.args.groupBy, tt.args.having, tt.args.orderBy)
got, err := generateAggregateClause(tt.args.panelType, tt.args.start, tt.args.end, tt.args.op, tt.args.aggKey, tt.args.step, tt.args.timeFilter, tt.args.whereClause, tt.args.groupBy, tt.args.having, tt.args.orderBy)
if (err != nil) != tt.wantErr {
t.Errorf("generateAggreagteClause() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -367,8 +367,15 @@ func buildTracesQuery(start, end, step int64, mq *v3.BuilderQuery, panelType v3.
v3.AggregateOperatorRateAvg,
v3.AggregateOperatorRateMin,
v3.AggregateOperatorRate:
rate := float64(step)
if panelType == v3.PanelTypeTable {
// if the panel type is table the denominator will be the total time range
duration := tracesEnd - tracesStart
if duration >= 0 {
rate = float64(duration) / NANOSECOND
}
}
op := fmt.Sprintf("%s(%s)/%f", tracesV3.AggregateOperatorToSQLFunc[mq.AggregateOperator], aggregationKey, rate)
query := fmt.Sprintf(queryTmpl, op, filterSubQuery, groupBy, having, orderBy)
return query, nil

View File

@@ -693,6 +693,38 @@ func Test_buildTracesQuery(t *testing.T) {
want: "SELECT max(toUnixTimestamp64Nano(timestamp)) as value from signoz_traces.distributed_signoz_index_v3 where (timestamp >= '1680066360726210000' AND timestamp <= '1680066458000000000') AND " +
"(ts_bucket_start >= 1680064560 AND ts_bucket_start <= 1680066458) order by value DESC",
},
{
name: "test rate for graph panel",
args: args{
panelType: v3.PanelTypeGraph,
start: 1745315470000000000,
end: 1745319070000000000,
step: 60,
mq: &v3.BuilderQuery{
AggregateOperator: v3.AggregateOperatorRate,
StepInterval: 60,
Filters: &v3.FilterSet{},
},
},
want: "SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, count()/60.000000 as value from signoz_traces.distributed_signoz_index_v3 where (timestamp >= '1745315470000000000' AND " +
"timestamp <= '1745319070000000000') AND (ts_bucket_start >= 1745313670 AND ts_bucket_start <= 1745319070) group by ts order by value DESC",
},
{
name: "test rate for table panel",
args: args{
panelType: v3.PanelTypeTable,
start: 1745315470000000000,
end: 1745319070000000000,
step: 60,
mq: &v3.BuilderQuery{
AggregateOperator: v3.AggregateOperatorRate,
StepInterval: 60,
Filters: &v3.FilterSet{},
},
},
want: "SELECT count()/3600.000000 as value from signoz_traces.distributed_signoz_index_v3 where (timestamp >= '1745315470000000000' AND " +
"timestamp <= '1745319070000000000') AND (ts_bucket_start >= 1745313670 AND ts_bucket_start <= 1745319070) order by value DESC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {