Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
13117d2b03 chore: add basic integration tests for meter 2026-03-01 21:53:56 +05:30
40 changed files with 590 additions and 1690 deletions

View File

@@ -5459,10 +5459,6 @@ paths:
name: searchText
schema:
type: string
- in: query
name: source
schema:
type: string
responses:
"200":
content:

View File

@@ -3231,11 +3231,6 @@ export type ListMetricsParams = {
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
source?: string;
};
export type ListMetrics200 = {

View File

@@ -60,30 +60,11 @@
gap: 8px;
margin-left: 108px;
position: relative;
/* Vertical dashed line connecting query elements */
&::after {
content: '';
position: absolute;
left: -28px;
top: 0;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content,
.metrics-container {
.metrics-aggregation-section-content {
position: relative;
&::before {
@@ -121,10 +102,6 @@
.qb-elements-container {
margin-left: 0px;
&::after {
display: none;
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
@@ -356,7 +333,28 @@
text-transform: uppercase;
&::before {
display: none;
content: '';
height: 120px;
content: '';
position: absolute;
left: 0;
top: 31px;
bottom: 0;
width: 1px;
background: repeating-linear-gradient(
to bottom,
#1d212d,
#1d212d 4px,
transparent 4px,
transparent 8px
);
left: 15px;
}
&.has-trace-operator {
&::before {
height: 0px;
}
}
}
@@ -464,21 +462,10 @@
.qb-content-section {
.qb-elements-container {
&::after {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
.code-mirror-where-clause,
.query-aggregation-container,
.query-add-ons,
.metrics-aggregation-section-content,
.metrics-container {
.metrics-aggregation-section-content {
&::before {
border-left: 6px dotted var(--bg-vanilla-300);
}
@@ -542,6 +529,18 @@
.qb-entity-options {
.options {
.query-name {
&::before {
background: repeating-linear-gradient(
to bottom,
var(--bg-vanilla-300),
var(--bg-vanilla-300) 4px,
transparent 4px,
transparent 8px
);
}
}
.formula-name {
&::before {
background: repeating-linear-gradient(

View File

@@ -207,7 +207,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}

View File

@@ -1,13 +1,14 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
@@ -43,12 +44,21 @@ export const MetricsSelect = memo(function MetricsSelect({
signalSourceChangeEnabled: boolean;
savePreviousQuery: boolean;
}): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
const { handleChangeAggregatorAttribute } = useQueryOperations({
index,
query,
entityVersion: version,
});
const handleAggregatorAttributeChange = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean) => {
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
},
[handleChangeAggregatorAttribute, attributeKeys],
);
const {
updateAllQueriesOperators,
handleSetQueryData,
@@ -154,10 +164,12 @@ export const MetricsSelect = memo(function MetricsSelect({
/>
)}
<MetricNameSelector
onChange={handleChangeAggregatorAttribute}
<AggregatorFilter
onChange={handleAggregatorAttributeChange}
query={query}
index={index}
signalSource={signalSource || ''}
setAttributeKeys={setAttributeKeys}
/>
</div>
);

View File

@@ -202,8 +202,8 @@ function QueryAddOns({
} else {
filteredAddOns = Object.values(ADD_ONS);
// Filter out group_by for metrics data source
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);

View File

@@ -43,7 +43,6 @@ jest.mock(
);
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: (): JSX.Element => <div />,
MetricNameSelector: (): JSX.Element => <div />,
}));
// Mock hooks
jest.mock('hooks/queryBuilder/useQueryBuilder');

View File

@@ -177,7 +177,7 @@ export const initialQueryBuilderFormValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MetricAggregateOperator.AVG,
timeAggregation: MetricAggregateOperator.COUNT,
spaceAggregation: MetricAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},
@@ -225,7 +225,7 @@ export const initialQueryBuilderFormMeterValues: IBuilderQuery = {
{
metricName: '',
temporality: '',
timeAggregation: MeterAggregateOperator.AVG,
timeAggregation: MeterAggregateOperator.COUNT,
spaceAggregation: MeterAggregateOperator.SUM,
reduceTo: ReduceOperators.AVG,
},

View File

@@ -441,7 +441,7 @@ describe('Footer utils', () => {
reduceTo: undefined,
spaceAggregation: 'sum',
temporality: undefined,
timeAggregation: 'avg',
timeAggregation: 'count',
},
],
disabled: false,

View File

@@ -6,7 +6,6 @@ import { isAxiosError } from 'axios';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
@@ -116,34 +115,27 @@ function TimeSeries(): JSX.Element {
setYAxisUnit(value);
};
const hasMetricSelected = useMemo(
() => currentQuery.builder.queryData.some((q) => q.aggregateAttribute?.key),
[currentQuery],
);
return (
<div className="meter-time-series-container">
<BuilderUnitsFilter onChange={onUnitChangeHandler} yAxisUnit={yAxisUnit} />
<div className="time-series-container">
{!hasMetricSelected && <EmptyMetricsSearch />}
{hasMetricSelected &&
responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
{responseData.map((datapoint, index) => (
<div
className="time-series-view-panel"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading}
data={datapoint}
dataSource={DataSource.METRICS}
yAxisUnit={yAxisUnit}
panelType={PANEL_TYPES.BAR}
/>
</div>
))}
</div>
</div>
);

View File

@@ -1,21 +1,13 @@
import { Typography } from 'antd';
import { Empty } from 'antd/lib';
interface EmptyMetricsSearchProps {
hasQueryResult?: boolean;
}
export default function EmptyMetricsSearch({
hasQueryResult,
}: EmptyMetricsSearchProps): JSX.Element {
export default function EmptyMetricsSearch(): JSX.Element {
return (
<div className="empty-metrics-search">
<Empty
description={
<Typography.Title level={5}>
{hasQueryResult
? 'No data'
: 'Select a metric and run a query to see the results'}
Please build and run a valid query to see the result
</Typography.Title>
}
/>

View File

@@ -69,7 +69,7 @@ function Explorer(): JSX.Element {
!isMetricUnitsLoading &&
!isMetricUnitsError &&
units.length > 0 &&
units.every((unit) => unit === units[0]),
units.every((unit) => unit && unit === units[0]),
[units, isMetricUnitsLoading, isMetricUnitsError],
);

View File

@@ -28,7 +28,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import EmptyMetricsSearch from './EmptyMetricsSearch';
import { TimeSeriesProps } from './types';
import {
buildUpdateMetricYAxisUnitPayload,
@@ -210,7 +209,7 @@ function TimeSeries({
{showSaveUnitButton && (
<div className="save-unit-container">
<Typography.Text>
Set the selected unit as the metric unit?
Save the selected unit for this metric?
</Typography.Text>
<Button
type="primary"
@@ -230,71 +229,64 @@ function TimeSeries({
'time-series-container': changeLayoutForOneChartPerQuery,
})}
>
{metricNames.length === 0 && <EmptyMetricsSearch />}
{metricNames.length > 0 &&
responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
{responseData.map((datapoint, index) => {
const isQueryDataItem = index < metricNames.length;
const metricName = isQueryDataItem ? metricNames[index] : undefined;
const metricUnit = isQueryDataItem ? metricUnits[index] : undefined;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
// Show the no unit warning if -
// 1. The metric query is not loading
// 2. The metric units are not loading
// 3. There are more than one metric
// 4. The current metric unit is empty
// 5. Is a queryData item
const isMetricUnitEmpty =
isQueryDataItem &&
!queries[index].isLoading &&
!isMetricUnitsLoading &&
metricUnits.length > 1 &&
!metricUnit &&
metricName;
const currentYAxisUnit = yAxisUnit || metricUnit;
const currentYAxisUnit = yAxisUnit || metricUnit;
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
No unit is set for this metric. You can assign one from the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle
size={16}
color={Color.BG_AMBER_400}
role="img"
aria-label="no unit warning"
/>
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
return (
<div
className="time-series-view"
// eslint-disable-next-line react/no-array-index-key
key={index}
>
{isMetricUnitEmpty && metricName && (
<Tooltip
className="no-unit-warning"
title={
<Typography.Text>
This metric does not have a unit. Please set one for it in the{' '}
<Typography.Link
onClick={(): void => handleOpenMetricDetails(metricName)}
>
metric details
</Typography.Link>{' '}
page.
</Typography.Text>
}
>
<AlertTriangle size={16} color={Color.BG_AMBER_400} />
</Tooltip>
)}
<TimeSeriesView
isFilterApplied={false}
isError={queries[index].isError}
isLoading={queries[index].isLoading || isMetricUnitsLoading}
data={datapoint}
yAxisUnit={currentYAxisUnit}
dataSource={DataSource.METRICS}
error={queries[index].error as APIError}
setWarning={setWarning}
/>
</div>
);
})}
</div>
</>
);

View File

@@ -1,19 +0,0 @@
import { render, screen } from '@testing-library/react';
import EmptyMetricsSearch from '../EmptyMetricsSearch';
describe('EmptyMetricsSearch', () => {
it('shows select metric message when no query has been run', () => {
render(<EmptyMetricsSearch />);
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
});
it('shows no data message when a query returned empty results', () => {
render(<EmptyMetricsSearch hasQueryResult />);
expect(screen.getByText('No data')).toBeInTheDocument();
});
});

View File

@@ -8,7 +8,7 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap } from 'constants/queryBuilder';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import * as useOptionsMenuHooks from 'container/OptionsMenu';
import * as useUpdateDashboardHooks from 'hooks/dashboard/useUpdateDashboard';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
@@ -157,6 +157,26 @@ describe('Explorer', () => {
jest.clearAllMocks();
});
it('should render Explorer query builder with metrics datasource selected', () => {
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
stagedQuery: initialQueriesMap[DataSource.TRACES],
} as any);
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.TIME_SERIES,
DataSource.METRICS,
);
});
it('should enable one chart per query toggle when oneChartPerQuery=true in URL', () => {
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'true' }),
@@ -221,46 +241,20 @@ describe('Explorer', () => {
expect(yAxisUnitSelector).not.toBeInTheDocument();
});
it('one chart per query toggle should be forced on and disabled when multiple metrics have different units', () => {
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
it('should hide y axis unit selector for multiple metrics with different units', () => {
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [
{ ...MOCK_METRIC_METADATA, unit: 'seconds' },
{ ...MOCK_METRIC_METADATA, unit: 'bytes' },
],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const yAxisUnitSelector = screen.queryByTestId(Y_AXIS_UNIT_SELECTOR_TEST_ID);
expect(yAxisUnitSelector).not.toBeInTheDocument();
// One chart per query toggle should be disabled
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeChecked();
expect(oneChartPerQueryToggle).toBeDisabled();
});
@@ -333,53 +327,4 @@ describe('Explorer', () => {
const oneChartPerQueryToggle = screen.getByRole('switch');
expect(oneChartPerQueryToggle).toBeEnabled();
});
it('one chart per query toggle should be enabled when multiple metrics have no unit', () => {
const metricWithNoUnit = {
type: MetrictypesTypeDTO.sum,
description: 'metric without unit',
unit: '',
temporality: MetrictypesTemporalityDTO.cumulative,
isMonotonic: true,
};
const mockQueryData = {
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
aggregateAttribute: {
...(initialQueriesMap[DataSource.METRICS].builder.queryData[0]
.aggregateAttribute as BaseAutocompleteData),
key: 'metric1',
},
};
const mockStagedQueryWithMultipleQueries = {
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [mockQueryData, mockQueryData],
},
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue(({
...mockUseQueryBuilderData,
stagedQuery: mockStagedQueryWithMultipleQueries,
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
jest.spyOn(useGetMetricsHooks, 'useGetMetrics').mockReturnValue({
isLoading: false,
isError: false,
metrics: [metricWithNoUnit, metricWithNoUnit],
});
(useSearchParams as jest.Mock).mockReturnValue([
new URLSearchParams({ isOneChartPerQueryEnabled: 'false' }),
mockSetSearchParams,
]);
renderExplorer();
const oneChartPerQueryToggle = screen.getByRole('switch');
// Toggle should be enabled (not forced/disabled) since both metrics
// have the same unit (no unit) and should be viewable on the same graph
expect(oneChartPerQueryToggle).toBeEnabled();
expect(oneChartPerQueryToggle).not.toBeChecked();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
@@ -56,7 +56,7 @@ const mockSetYAxisUnit = jest.fn();
function renderTimeSeries(
overrides: Partial<TimeSeriesProps> = {},
): ReturnType<typeof render> {
): RenderResult {
return render(
<TimeSeries
showOneChartPerQuery={false}
@@ -84,57 +84,45 @@ describe('TimeSeries', () => {
} as Partial<UseUpdateMetricMetadataReturnType>) as UseUpdateMetricMetadataReturnType);
});
it('shows select metric message when no metric is selected', () => {
renderTimeSeries({ metricNames: [] });
expect(
screen.getByText('Select a metric and run a query to see the results'),
).toBeInTheDocument();
expect(screen.queryByText('TimeSeriesView')).not.toBeInTheDocument();
});
it('renders chart view when a metric is selected', () => {
renderTimeSeries({
metricNames: ['metric1'],
metricUnits: ['count'],
metrics: [MOCK_METRIC_METADATA],
});
expect(screen.getByText('TimeSeriesView')).toBeInTheDocument();
expect(
screen.queryByText('Select a metric and run a query to see the results'),
).not.toBeInTheDocument();
});
it('should render a warning icon when a metric has no unit among multiple metrics', () => {
renderTimeSeries({
const user = userEvent.setup();
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [undefined, undefined],
});
expect(
screen.getByRole('img', { name: 'no unit warning' }),
).toBeInTheDocument();
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
waitFor(() =>
expect(
screen.findByText('This metric does not have a unit'),
).toBeInTheDocument(),
);
});
it('warning tooltip shows metric details link', async () => {
it('clicking on warning icon tooltip should open metric details modal', async () => {
const user = userEvent.setup();
renderTimeSeries({
const { container } = renderTimeSeries({
metricUnits: ['', 'count'],
metricNames: ['metric1', 'metric2'],
metrics: [MOCK_METRIC_METADATA, MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
});
const alertIcon = screen.getByRole('img', { name: 'no unit warning' });
await user.hover(alertIcon);
const alertIcon = container.querySelector('.no-unit-warning') as HTMLElement;
user.hover(alertIcon);
expect(await screen.findByText('metric details')).toBeInTheDocument();
const metricDetailsLink = await screen.findByText('metric details');
user.click(metricDetailsLink);
waitFor(() =>
expect(mockSetIsMetricDetailsOpen).toHaveBeenCalledWith('metric1'),
);
});
it('shows save unit prompt with enabled button when metric has no unit and a unit is selected', async () => {
renderTimeSeries({
it('shows Save unit button when metric had no unit but one is selected', async () => {
const { findByText, getByRole } = renderTimeSeries({
metricUnits: [undefined],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
@@ -143,10 +131,38 @@ describe('TimeSeries', () => {
});
expect(
await screen.findByText('Set the selected unit as the metric unit?'),
await findByText('Save the selected unit for this metric?'),
).toBeInTheDocument();
const yesButton = screen.getByRole('button', { name: 'Yes' });
const yesButton = getByRole('button', { name: 'Yes' });
expect(yesButton).toBeInTheDocument();
expect(yesButton).toBeEnabled();
});
it('clicking on save unit button shoould upated metric metadata', async () => {
const user = userEvent.setup();
const { getByRole } = renderTimeSeries({
metricUnits: [''],
metricNames: ['metric1'],
metrics: [MOCK_METRIC_METADATA],
yAxisUnit: 'seconds',
showYAxisUnitSelector: true,
});
const yesButton = getByRole('button', { name: /Yes/i });
await user.click(yesButton);
expect(mockUpdateMetricMetadata).toHaveBeenCalledWith(
{
pathParams: {
metricName: 'metric1',
},
data: expect.objectContaining({ unit: 'seconds' }),
},
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
);
});
});

View File

@@ -139,14 +139,4 @@ describe('getMetricUnits', () => {
expect(result).toHaveLength(1);
expect(result[0]).toBe('s');
});
it('should return undefined for metrics with no unit', () => {
const result = getMetricUnits([
{ ...MOCK_METRIC_METADATA, unit: '' },
{ ...MOCK_METRIC_METADATA, unit: '' },
]);
expect(result).toHaveLength(2);
expect(result[0]).toBeUndefined();
expect(result[1]).toBeUndefined();
});
});

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Typography } from 'antd';
import { initialQueriesMap } from 'constants/queryBuilder';
import { MetricNameSelector } from 'container/QueryBuilder/filters';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
@@ -27,7 +27,7 @@ function MetricNameSearch({
className="inspect-metrics-input-group metric-name-search"
>
<Typography.Text>From</Typography.Text>
<MetricNameSelector
<AggregatorFilter
defaultValue={searchText ?? ''}
query={initialQueriesMap[DataSource.METRICS].builder.queryData[0]}
onSelect={handleSetMetricName}

View File

@@ -1,9 +1,8 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as metricsService from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as appContextHooks from 'providers/App/App';
import store from 'store';
@@ -24,31 +23,27 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: jest.fn().mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics: [] } },
}),
useUpdateMetricMetadata: jest.fn().mockReturnValue({
mutate: jest.fn(),
isLoading: false,
}),
jest.mock('container/QueryBuilder/filters', () => ({
AggregatorFilter: ({ onSelect, onChange, defaultValue }: any): JSX.Element => (
<div data-testid="mock-aggregator-filter">
<input
data-testid="metric-name-input"
defaultValue={defaultValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>): void =>
onChange({ key: e.target.value })
}
/>
<button
type="button"
data-testid="select-metric-button"
onClick={(): void => onSelect({ key: 'test_metric_2' })}
>
Select Metric
</button>
</div>
),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}),
);
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
@@ -128,24 +123,6 @@ describe('QueryBuilder', () => {
it('should call setCurrentMetricName when metric name is selected', async () => {
const user = userEvent.setup();
(metricsService.useListMetrics as jest.Mock).mockReturnValue({
isFetching: false,
isError: false,
data: {
data: {
metrics: [
{
metricName: 'test_metric_2',
type: 'Sum',
isMonotonic: true,
description: '',
temporality: 'cumulative',
unit: '',
},
],
},
},
});
render(
<QueryClientProvider client={queryClient}>
@@ -160,12 +137,8 @@ describe('QueryBuilder', () => {
expect(screen.getByText('From')).toBeInTheDocument();
const input = within(metricNameSearch).getByRole('combobox');
fireEvent.change(input, { target: { value: 'test_metric_2' } });
const options = document.querySelectorAll('.ant-select-item');
expect(options.length).toBeGreaterThan(0);
await user.click(options[0] as HTMLElement);
const selectButton = screen.getByTestId('select-metric-button');
await user.click(selectButton);
expect(mockSetCurrentMetricName).toHaveBeenCalledWith('test_metric_2');
});

View File

@@ -24,7 +24,6 @@ import {
AggregatorFilter,
GroupByFilter,
HavingFilter,
MetricNameSelector,
OperatorsSelect,
OrderByFilter,
ReduceToFilter,
@@ -404,7 +403,7 @@ export const Query = memo(function Query({
)}
<Col flex="auto">
<MetricNameSelector
<AggregatorFilter
onChange={handleChangeAggregatorAttribute}
query={query}
/>

View File

@@ -1,6 +0,0 @@
.metric-name-selector {
.ant-select-selection-placeholder {
color: var(--bg-slate-200);
font-style: italic;
}
}

View File

@@ -1,887 +0,0 @@
import { useEffect, useState } from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { MetricNameSelector } from './MetricNameSelector';
const mockUseListMetrics = jest.fn();
jest.mock('api/generated/services/metrics', () => ({
useListMetrics: (...args: unknown[]): ReturnType<typeof mockUseListMetrics> =>
mockUseListMetrics(...args),
}));
jest.mock('hooks/useDebounce', () => ({
__esModule: true,
default: <T,>(value: T): T => value,
}));
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));
// Ref lets StatefulMetricQueryHarness wire handleSetQueryData to real state,
// while other tests keep the default no-op mock.
const handleSetQueryDataRef: {
current: (index: number, query: IBuilderQuery) => void;
} = {
current: jest.fn(),
};
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
handleSetQueryData: (index: number, query: IBuilderQuery): void =>
handleSetQueryDataRef.current(index, query),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
panelType: 'TIME_SERIES',
initialDataSource: DataSource.METRICS,
currentQuery: {
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
queryType: 'builder',
},
setLastUsedQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
}),
}));
function makeMetric(
overrides: Partial<MetricsexplorertypesListMetricDTO> = {},
): MetricsexplorertypesListMetricDTO {
return {
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
}
function makeQuery(overrides: Partial<IBuilderQuery> = {}): IBuilderQuery {
return {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregateOperator: 'count',
aggregateAttribute: { key: '', type: '', dataType: DataTypes.Float64 },
timeAggregation: 'avg',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
...overrides,
} as IBuilderQuery;
}
function returnMetrics(
metrics: MetricsexplorertypesListMetricDTO[],
overrides: Record<string, unknown> = {},
): void {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: false,
data: { data: { metrics } },
queryKey: ['/api/v2/metrics'],
...overrides,
});
}
// snippet so tests can assert on them.
function MetricQueryHarness({ query }: { query: IBuilderQuery }): JSX.Element {
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
</div>
);
}
function getOptionLabels(testId: string): string[] {
const list = screen.getByTestId(testId);
const items = within(list).queryAllByRole('listitem');
return items.map((el) => el.textContent || '');
}
describe('MetricNameSelector', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('shows metric names from API as dropdown options', () => {
returnMetrics([
makeMetric({ metricName: 'http_requests_total' }),
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'h' } });
expect(
screen.getAllByText('http_requests_total').length,
).toBeGreaterThanOrEqual(1);
expect(
screen.getAllByText('cpu_usage_percent').length,
).toBeGreaterThanOrEqual(1);
});
it('retains typed metric name in input after blur', () => {
returnMetrics([makeMetric({ metricName: 'http_requests_total' })]);
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(input).toHaveValue('http_requests_total');
});
it('shows error message when API request fails', () => {
mockUseListMetrics.mockReturnValue({
isFetching: false,
isError: true,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
render(<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />);
const input = screen.getByRole('combobox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'test' } });
expect(screen.getByText('Failed to load metrics')).toBeInTheDocument();
});
it('shows loading spinner while fetching metrics', () => {
mockUseListMetrics.mockReturnValue({
isFetching: true,
isError: false,
data: undefined,
queryKey: ['/api/v2/metrics'],
});
const { container } = render(
<MetricNameSelector query={makeQuery()} onChange={jest.fn()} />,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'test' } });
expect(container.querySelector('.ant-spin-spinning')).toBeInTheDocument();
});
});
describe('selecting a metric type updates the aggregation options', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum metric shows Rate/Increase time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'http_requests_total',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'http_requests_total' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual(['Rate', 'Increase']);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Gauge metric shows Latest/Sum/Avg/Min/Max/Count/Count Distinct time options and Sum/Avg/Min/Max space options', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage_percent',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'cpu_usage_percent' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'active_connections' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_seconds',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_seconds' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('ExponentialHistogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({
metricName: 'request_duration_exp',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'request_duration_exp' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([]);
expect(getOptionLabels('space-agg-options')).toEqual([
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
it('unknown metric (typed name not in API results) shows all time and space options', () => {
returnMetrics([makeMetric({ metricName: 'known_metric' })]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Max',
'Min',
'Sum',
'Avg',
'Count',
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
'P50',
'P75',
'P90',
'P95',
'P99',
]);
});
});
// these tests require the previous state, so we setup it to
// tracks previousMetricInfo across metric selections
function StatefulMetricQueryHarness({
initialQuery,
}: {
initialQuery: IBuilderQuery;
}): JSX.Element {
const [query, setQuery] = useState(initialQuery);
useEffect(() => {
handleSetQueryDataRef.current = (
_index: number,
newQuery: IBuilderQuery,
): void => {
setQuery(newQuery);
};
return (): void => {
handleSetQueryDataRef.current = jest.fn();
};
}, []);
const {
handleChangeAggregatorAttribute,
operators,
spaceAggregationOptions,
} = useQueryOperations({
query,
index: 0,
entityVersion: ENTITY_VERSION_V5,
});
const currentAggregation = query.aggregations?.[0] as MetricAggregation;
return (
<div>
<MetricNameSelector
query={query}
onChange={handleChangeAggregatorAttribute}
/>
<ul data-testid="time-agg-options">
{operators.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<ul data-testid="space-agg-options">
{spaceAggregationOptions.map((op) => (
<li key={op.value}>{op.label}</li>
))}
</ul>
<div data-testid="selected-time-agg">
{currentAggregation?.timeAggregation || ''}
</div>
<div data-testid="selected-space-agg">
{currentAggregation?.spaceAggregation || ''}
</div>
<div data-testid="selected-metric-name">
{currentAggregation?.metricName || ''}
</div>
</div>
);
}
describe('switching between metrics of the same type preserves aggregation settings', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum: preserves non-default increase/avg when switching to another Sum metric', () => {
returnMetrics([
makeMetric({
metricName: 'metric_a',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'metric_b',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'metric_a',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'metric_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'metric_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('increase');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'metric_b',
);
});
it('Gauge: preserves non-default min/max when switching to another Gauge metric', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'mem_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'mem_usage' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'mem_usage',
);
});
it('Histogram: preserves non-default p99 when switching to another Histogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'req_duration',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'db_latency',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'req_duration',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'req_duration',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'db_latency' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p99');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'db_latency',
);
});
it('ExponentialHistogram: preserves non-default p75 when switching to another ExponentialHistogram metric', () => {
returnMetrics([
makeMetric({
metricName: 'exp_hist_a',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
makeMetric({
metricName: 'exp_hist_b',
type: MetrictypesTypeDTO.exponentialhistogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'exp_hist_a',
type: ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p75',
metricName: 'exp_hist_a',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'exp_hist_b' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p75');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'exp_hist_b',
);
});
});
describe('switching to a different metric type resets aggregation to new defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Sum to Gauge: resets from increase/avg to the Gauge defaults avg/avg', () => {
returnMetrics([
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'sum_metric',
type: ATTRIBUTE_TYPES.SUM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'increase',
spaceAggregation: 'avg',
metricName: 'sum_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'gauge_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'gauge_metric',
);
});
it('Gauge to Histogram: resets from min/max to the Histogram defaults (no time, p90 space)', () => {
returnMetrics([
makeMetric({
metricName: 'gauge_metric',
type: MetrictypesTypeDTO.gauge,
}),
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'gauge_metric',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'gauge_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'hist_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('p90');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'hist_metric',
);
});
it('Histogram to Sum: resets from p99 to the Sum defaults rate/sum', () => {
returnMetrics([
makeMetric({
metricName: 'hist_metric',
type: MetrictypesTypeDTO.histogram,
}),
makeMetric({
metricName: 'sum_metric',
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'hist_metric',
type: ATTRIBUTE_TYPES.HISTOGRAM,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: '',
spaceAggregation: 'p99',
metricName: 'hist_metric',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'sum_metric' } });
fireEvent.blur(input);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('rate');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('sum');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'sum_metric',
);
});
});
describe('typed metric not in search results is committed with unknown defaults', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('Gauge to unknown metric: resets from Gauge aggregations to unknown defaults (avg/avg)', () => {
returnMetrics([
makeMetric({
metricName: 'cpu_usage',
type: MetrictypesTypeDTO.gauge,
}),
]);
render(
<StatefulMetricQueryHarness
initialQuery={makeQuery({
aggregateAttribute: {
key: 'cpu_usage',
type: ATTRIBUTE_TYPES.GAUGE,
dataType: DataTypes.Float64,
},
aggregations: [
{
timeAggregation: 'min',
spaceAggregation: 'max',
metricName: 'cpu_usage',
temporality: '',
},
] as MetricAggregation[],
})}
/>,
);
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('min');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('max');
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'unknown_metric' } });
fireEvent.blur(input);
// Metric not in search results is committed with empty type resets to unknown defaults
expect(screen.getByTestId('selected-time-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-space-agg')).toHaveTextContent('avg');
expect(screen.getByTestId('selected-metric-name')).toHaveTextContent(
'unknown_metric',
);
});
});
describe('Summary metric type is treated as Gauge', () => {
beforeEach(() => {
jest.clearAllMocks();
handleSetQueryDataRef.current = jest.fn();
returnMetrics([]);
});
it('selecting a Summary metric shows Gauge aggregation options', () => {
returnMetrics([
makeMetric({
metricName: 'rpc_duration_summary',
type: MetrictypesTypeDTO.summary,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'rpc_duration_summary' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toEqual([
'Latest',
'Sum',
'Avg',
'Min',
'Max',
'Count',
'Count Distinct',
]);
expect(getOptionLabels('space-agg-options')).toEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
});

View File

@@ -1,282 +0,0 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { AutoComplete, Spin, Typography } from 'antd';
import { useListMetrics } from 'api/generated/services/metrics';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ATTRIBUTE_TYPES } from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { popupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';
// N.B on the metric name selector behaviour.
//
// Metric aggregation options resolution:
// The component maintains a ref (metricsRef) of the latest API results.
// When the user commits a metric name (via dropdown select, blur, or Cmd+Enter),
// resolveMetricFromText looks up the metric in metricsRef to determine its type
// (Sum, Gauge, Histogram, etc.). If the metric isn't found (e.g. the user typed
// a name before the debounced search returned), the type is empty and downstream
// treats it as unknown.
//
// Selection handling:
// - Dropdown select: user picks from the dropdown; type is always resolved
// since the option came from the current API results.
// - Blur: user typed a name and tabbed/clicked away without selecting from
// the dropdown. If the name differs from the current metric, it's resolved
// and committed. If the input is empty, it resets to the current metric name.
// - Cmd/Ctrl+Enter: resolves the typed name and commits it using flushSync
// so the state update is processed synchronously before QueryBuilderV2's
// onKeyDownCapture fires handleRunQuery. Uses document-level capture phase
// to run before React's root-level event dispatch. However, there is still one
// need to be handled here. TODO(srikanthccv): enter before n/w req completion
//
// Edit mode:
// When a saved query is loaded, the metric name may be set via aggregations
// but aggregateAttribute.type may be missing. Once the API returns metric data,
// the component calls onChange with isEditMode=true to backfill the type without
// resetting aggregation options.
//
// Signal source:
// When signalSource is 'meter', the API is filtered to meter metrics only.
// Changing signalSource clears the input and search text.
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
if (metric.type === MetrictypesTypeDTO.sum && !metric.isMonotonic) {
return ATTRIBUTE_TYPES.GAUGE;
}
const mapping: Record<MetrictypesTypeDTO, ATTRIBUTE_TYPES> = {
[MetrictypesTypeDTO.sum]: ATTRIBUTE_TYPES.SUM,
[MetrictypesTypeDTO.gauge]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.histogram]: ATTRIBUTE_TYPES.HISTOGRAM,
[MetrictypesTypeDTO.summary]: ATTRIBUTE_TYPES.GAUGE,
[MetrictypesTypeDTO.exponentialhistogram]:
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
};
return mapping[metric.type] || '';
}
function toAutocompleteData(
metricName: string,
type: string,
): BaseAutocompleteData {
return { key: metricName, type, dataType: DataTypes.Float64 };
}
export type MetricNameSelectorProps = {
query: IBuilderQuery;
onChange: (value: BaseAutocompleteData, isEditMode?: boolean) => void;
disabled?: boolean;
defaultValue?: string;
onSelect?: (value: BaseAutocompleteData) => void;
signalSource?: 'meter' | '';
};
export const MetricNameSelector = memo(function MetricNameSelector({
query,
onChange,
disabled,
defaultValue,
onSelect,
signalSource,
}: MetricNameSelectorProps): JSX.Element {
const currentMetricName =
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
query.aggregateAttribute?.key ||
'';
const [inputValue, setInputValue] = useState<string>(
defaultValue || currentMetricName,
);
const [searchText, setSearchText] = useState<string>(currentMetricName);
const metricsRef = useRef<MetricsexplorertypesListMetricDTO[]>([]);
const selectedFromDropdownRef = useRef(false);
const prevSignalSourceRef = useRef(signalSource);
useEffect(() => {
setInputValue(defaultValue || currentMetricName);
}, [defaultValue, currentMetricName]);
useEffect(() => {
if (prevSignalSourceRef.current !== signalSource) {
prevSignalSourceRef.current = signalSource;
setSearchText('');
setInputValue('');
}
}, [signalSource]);
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const { isFetching, isError, data: listMetricsData } = useListMetrics(
{
searchText: debouncedValue,
limit: 100,
source: signalSource || undefined,
} as Record<string, unknown>,
{
query: {
keepPreviousData: false,
retry: 2,
},
},
);
const metrics = useMemo(() => listMetricsData?.data?.metrics ?? [], [
listMetricsData,
]);
useEffect(() => {
metricsRef.current = metrics;
}, [metrics]);
const optionsData = useMemo((): ExtendedSelectOption[] => {
if (!metrics.length) {
return [];
}
return metrics.map((metric) => ({
label: (
<OptionRenderer
label={metric.metricName}
value={metric.metricName}
dataType={DataTypes.Float64}
type={getAttributeType(metric) || ''}
/>
),
value: metric.metricName,
key: metric.metricName,
}));
}, [metrics]);
useEffect(() => {
const metricName = (query.aggregations?.[0] as MetricAggregation)?.metricName;
const hasAggregateAttributeType = query.aggregateAttribute?.type;
if (metricName && !hasAggregateAttributeType && metrics.length > 0) {
const found = metrics.find((m) => m.metricName === metricName);
if (found) {
onChange(
toAutocompleteData(found.metricName, getAttributeType(found)),
true,
);
}
}
}, [metrics, query.aggregations, query.aggregateAttribute?.type, onChange]);
const resolveMetricFromText = useCallback(
(text: string): BaseAutocompleteData => {
const found = metricsRef.current.find((m) => m.metricName === text);
if (found) {
return toAutocompleteData(found.metricName, getAttributeType(found));
}
return toAutocompleteData(text, '');
},
[],
);
const placeholder = useMemo(() => {
if (signalSource === 'meter') {
return 'Search for a meter metric...';
}
return 'Search for a metric...';
}, [signalSource]);
const handleChange = useCallback((value: string): void => {
setInputValue(value);
}, []);
const handleSearch = useCallback((value: string): void => {
setSearchText(value);
selectedFromDropdownRef.current = false;
}, []);
const handleSelect = useCallback(
(value: string): void => {
selectedFromDropdownRef.current = true;
const resolved = resolveMetricFromText(value);
onChange(resolved);
if (onSelect) {
onSelect(resolved);
}
setSearchText('');
},
[onChange, onSelect, resolveMetricFromText],
);
const handleBlur = useCallback(() => {
if (selectedFromDropdownRef.current) {
selectedFromDropdownRef.current = false;
return;
}
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
onChange(resolveMetricFromText(typedValue));
} else if (!typedValue && currentMetricName) {
setInputValue(currentMetricName);
}
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
const typedValue = inputValue?.trim() || '';
if (typedValue && typedValue !== currentMetricName) {
flushSync(() => {
onChange(resolveMetricFromText(typedValue));
});
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return (): void => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [inputValue, currentMetricName, onChange, resolveMetricFromText]);
return (
<AutoComplete
className="metric-name-selector"
getPopupContainer={popupContainer}
style={selectStyle}
filterOption={false}
placeholder={placeholder}
onSearch={handleSearch}
onChange={handleChange}
notFoundContent={
isFetching ? (
<Spin size="small" />
) : isError ? (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
Failed to load metrics
</Typography.Text>
) : null
}
options={optionsData}
value={inputValue}
onBlur={handleBlur}
onSelect={handleSelect}
disabled={disabled}
/>
);
});

View File

@@ -1,2 +0,0 @@
export type { MetricNameSelectorProps } from './MetricNameSelector';
export { MetricNameSelector } from './MetricNameSelector';

View File

@@ -2,7 +2,6 @@ export { AggregatorFilter } from './AggregatorFilter';
export { BuilderUnitsFilter } from './BuilderUnitsFilter';
export { GroupByFilter } from './GroupByFilter';
export { HavingFilter } from './HavingFilter';
export { MetricNameSelector } from './MetricNameSelector';
export { OperatorsSelect } from './OperatorsSelect';
export { OrderByFilter } from './OrderByFilter';
export { ReduceToFilter } from './ReduceToFilter';

View File

@@ -257,9 +257,7 @@ function TimeSeriesView({
chartData[0]?.length === 0 &&
!isLoading &&
!isError &&
dataSource === DataSource.METRICS && (
<EmptyMetricsSearch hasQueryResult={data !== undefined} />
)}
dataSource === DataSource.METRICS && <EmptyMetricsSearch />}
{!isLoading &&
!isError &&

View File

@@ -248,12 +248,19 @@ export const useQueryOperations: UseQueryOperations = ({
);
const handleChangeAggregatorAttribute = useCallback(
(value: BaseAutocompleteData, isEditMode?: boolean): void => {
(
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
): void => {
const newQuery: IBuilderQuery = {
...query,
aggregateAttribute: value,
};
const getAttributeKeyFromMetricName = (metricName: string): string =>
attributeKeys?.find((key) => key.key === metricName)?.type || '';
if (
newQuery.dataSource === DataSource.METRICS &&
entityVersion === ENTITY_VERSION_V4
@@ -304,7 +311,9 @@ export const useQueryOperations: UseQueryOperations = ({
// Get current metric info
const currentMetricType = newQuery.aggregateAttribute?.type || '';
const prevMetricType = previousMetricInfo?.type || '';
const prevMetricType = previousMetricInfo?.type
? previousMetricInfo.type
: getAttributeKeyFromMetricName(previousMetricInfo?.name || '');
// Check if metric type has changed by comparing with tracked previous values
const metricTypeChanged =
@@ -365,7 +374,7 @@ export const useQueryOperations: UseQueryOperations = ({
// Handled query with unknown metric to avoid 400 and 500 errors
// With metric value typed and not available then - time - 'avg', space - 'avg'
// If not typed - time - 'avg', space - 'sum'
// If not typed - time - 'rate', space - 'sum', op - 'count'
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
@@ -379,7 +388,7 @@ export const useQueryOperations: UseQueryOperations = ({
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
timeAggregation: MetricAggregateOperator.COUNT,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
@@ -399,29 +408,6 @@ export const useQueryOperations: UseQueryOperations = ({
];
}
}
// Override with safe defaults when metric type is unknown to avoid 400/500 errors
if (isEmpty(newQuery.aggregateAttribute?.type)) {
if (!isEmpty(newQuery.aggregateAttribute?.key)) {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.AVG,
},
];
} else {
newQuery.aggregations = [
{
timeAggregation: MetricAggregateOperator.AVG,
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
spaceAggregation: MetricAggregateOperator.SUM,
},
];
}
}
}
}

View File

@@ -54,7 +54,7 @@ export const stepIntervalUnchanged = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -177,7 +177,7 @@ export const replaceVariables = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -267,7 +267,7 @@ export const defaultOutput = {
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
},
],
filter: { expression: '' },
@@ -392,7 +392,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},
@@ -429,7 +429,7 @@ export const outputWithFunctions = {
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
timeAggregation: 'count',
spaceAggregation: 'sum',
reduceTo: ReduceOperators.AVG,
},

View File

@@ -72,6 +72,7 @@ export type UseQueryOperations = (
handleChangeAggregatorAttribute: (
value: BaseAutocompleteData,
isEditMode?: boolean,
attributeKeys?: BaseAutocompleteData[],
) => void;
handleChangeDataSource: (newSource: DataSource) => void;
handleDeleteQuery: () => void;

View File

@@ -14,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymeter"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -57,81 +56,11 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
// TODO(srikanthccv): use metadata store to fetch metric metadata
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
if params.Source == "meter" {
return m.listMeterMetrics(ctx, params)
}
return m.listMetrics(ctx, orgID, params)
}
func (m *module) listMeterMetrics(ctx context.Context, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"any(description) AS description",
"any(type) AS metric_type",
"any(unit) AS metric_unit",
"argMax(temporality, unix_milli) AS temporality",
"any(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymeter.DBName, telemetrymeter.SamplesTableName))
if params.Start != nil && params.End != nil {
sb.Where(sb.Between("unix_milli", *params.Start, *params.End))
}
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.GroupBy("metric_name")
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list meter metrics")
}
defer rows.Close()
metrics := make([]metricsexplorertypes.ListMetric, 0)
for rows.Next() {
var metric metricsexplorertypes.ListMetric
if err := rows.Scan(
&metric.MetricName,
&metric.Description,
&metric.MetricType,
&metric.MetricUnit,
&metric.Temporality,
&metric.IsMonotonic,
); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan meter metric")
}
metrics = append(metrics, metric)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating meter metrics")
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) listMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")

View File

@@ -1631,10 +1631,8 @@ func (t *telemetryMetaStore) FetchTemporalityAndTypeMulti(ctx context.Context, q
if err != nil {
return nil, nil, err
}
meterMetricsTemporality, meterMetricsTypes, err := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
if err != nil {
return nil, nil, err
}
// TODO: return error after table migration are run
meterMetricsTemporality, meterMetricsTypes, _ := t.fetchMeterSourceMetricsTemporalityAndType(ctx, metricNames...)
// For metrics not found in the database, set to Unknown
for _, metricName := range metricNames {
@@ -1730,7 +1728,6 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
"metric_name",
"argMax(temporality, unix_milli) as temporality",
"any(type) AS type",
"any(is_monotonic) as is_monotonic",
).From(t.meterDBName + "." + t.meterFieldsTblName)
// Filter by metric names (in the temporality column due to data mix-up)

View File

@@ -301,7 +301,6 @@ type ListMetricsParams struct {
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
Source string `query:"source"`
}
// Validate ensures ListMetricsParams contains acceptable values.

View File

@@ -447,7 +447,7 @@ type MetricAggregation struct {
// space aggregation to apply to the query
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation"`
// param for space aggregation if needed
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam"`
// table hints to use for the query
TableHints *metrictypes.MetricTableHints `json:"-"`
// value filter to apply to the query

View File

@@ -15,6 +15,7 @@ pytest_plugins = [
"fixtures.logs",
"fixtures.traces",
"fixtures.metrics",
"fixtures.meter",
"fixtures.driver",
"fixtures.idp",
"fixtures.idputils",

View File

@@ -0,0 +1,121 @@
import hashlib
import json
from datetime import datetime, timedelta
from typing import Any, Callable, Generator, List
import numpy as np
import pytest
from fixtures import types
class MeterSample:
temporality: str
metric_name: str
description: str
unit: str
type: str
is_monotonic: bool
labels: str
fingerprint: np.uint64
unix_milli: np.int64
value: np.float64
def __init__(
self,
metric_name: str,
labels: dict[str, str],
timestamp: datetime,
value: float,
temporality: str = "Delta",
description: str = "",
unit: str = "",
type_: str = "Sum",
is_monotonic: bool = True,
) -> None:
self.temporality = temporality
self.metric_name = metric_name
self.description = description
self.unit = unit
self.type = type_
self.is_monotonic = is_monotonic
self.labels = json.dumps(labels, separators=(",", ":"))
self.unix_milli = np.int64(int(timestamp.timestamp() * 1e3))
self.value = np.float64(value)
fingerprint_str = metric_name + self.labels
self.fingerprint = np.uint64(
int(hashlib.md5(fingerprint_str.encode()).hexdigest()[:16], 16)
)
def to_samples_row(self) -> list:
return [
self.temporality,
self.metric_name,
self.description,
self.unit,
self.type,
self.is_monotonic,
self.labels,
self.fingerprint,
self.unix_milli,
self.value,
]
def make_meter_samples(
metric_name: str,
labels: dict[str, str],
now: datetime,
count: int = 60,
base_value: float = 100.0,
**kwargs,
) -> List[MeterSample]:
samples = []
for i in range(count):
ts = now - timedelta(minutes=count - i)
samples.append(
MeterSample(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=base_value + i,
**kwargs,
)
)
return samples
@pytest.fixture(name="insert_meter_samples", scope="function")
def insert_meter_samples(
clickhouse: types.TestContainerClickhouse,
) -> Generator[Callable[[List[MeterSample]], None], Any, None]:
def _insert_meter_samples(samples: List[MeterSample]) -> None:
if len(samples) == 0:
return
clickhouse.conn.insert(
database="signoz_meter",
table="distributed_samples",
column_names=[
"temporality",
"metric_name",
"description",
"unit",
"type",
"is_monotonic",
"labels",
"fingerprint",
"unix_milli",
"value",
],
data=[s.to_samples_row() for s in samples],
)
yield _insert_meter_samples
cluster = clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for table in ["samples", "samples_agg_1d"]:
clickhouse.conn.query(
f"TRUNCATE TABLE signoz_meter.{table} ON CLUSTER '{cluster}' SYNC"
)

View File

@@ -54,6 +54,7 @@ def build_builder_query(
*,
comparisonSpaceAggregationParam: Optional[Dict] = None,
temporality: Optional[str] = None,
source: Optional[str] = None,
step_interval: int = DEFAULT_STEP_INTERVAL,
group_by: Optional[List[str]] = None,
filter_expression: Optional[str] = None,
@@ -73,10 +74,14 @@ def build_builder_query(
"stepInterval": step_interval,
"disabled": disabled,
}
if source:
spec["source"] = source
if temporality:
spec["aggregations"][0]["temporality"] = temporality
if comparisonSpaceAggregationParam:
spec["aggregations"][0]["comparisonSpaceAggregationParam"] = comparisonSpaceAggregationParam
spec["aggregations"][0][
"comparisonSpaceAggregationParam"
] = comparisonSpaceAggregationParam
if group_by:
spec["groupBy"] = [
{

View File

@@ -2,7 +2,6 @@
Look at the histogram_data_1h.jsonl file for the relevant data
"""
import random
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
@@ -22,6 +21,7 @@ from fixtures.utils import get_testdata_file_path
FILE = get_testdata_file_path("histogram_data_1h.jsonl")
@pytest.mark.parametrize(
"threshold, operator, first_value, last_value",
[
@@ -29,12 +29,22 @@ FILE = get_testdata_file_path("histogram_data_1h.jsonl")
(100, "<=", 1.1, 6.9),
(7500, "<=", 16.75, 74.75),
(8000, "<=", 17, 75),
(80000, "<=", 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
"<=",
17,
75,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 7, 7),
(100, ">", 16.9, 69.1),
(7500, ">", 1.25, 1.25),
(8000, ">", 1, 1),
(80000, ">", 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
">",
1,
1,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_one_endpoint(
@@ -65,10 +75,7 @@ def test_histogram_count_for_one_endpoint(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
filter_expression='endpoint = "/health"',
)
@@ -81,6 +88,7 @@ def test_histogram_count_for_one_endpoint(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, first_value, last_value",
[
@@ -88,12 +96,22 @@ def test_histogram_count_for_one_endpoint(
(100, "<=", 2.2, 13.8),
(7500, "<=", 33.5, 149.5),
(8000, "<=", 34, 150),
(80000, "<=", 34, 150), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
"<=",
34,
150,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 14, 14),
(100, ">", 33.8, 138.2),
(7500, ">", 2.5, 2.5),
(8000, ">", 2, 2),
(80000, ">", 2, 2), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
">",
2,
2,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_one_service(
@@ -124,10 +142,7 @@ def test_histogram_count_for_one_service(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
filter_expression='service = "api"',
)
@@ -140,6 +155,7 @@ def test_histogram_count_for_one_service(
assert result_values[0]["value"] == first_value
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, zeroth_value, first_value, last_value",
[
@@ -147,12 +163,24 @@ def test_histogram_count_for_one_service(
(100, "<=", 1234.5, 1.1, 6.9),
(7500, "<=", 12345, 16.75, 74.75),
(8000, "<=", 12345, 17, 75),
(80000, "<=", 12345, 17, 75), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
"<=",
12345,
17,
75,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(1000, ">", 0, 7, 7),
(100, ">", 11110.5, 16.9, 69.1),
(7500, ">", 0, 1.25, 1.25),
(8000, ">", 0, 1, 1),
(80000, ">", 0, 1, 1), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
(
80000,
">",
0,
1,
1,
), ## cuz we don't know the max value in infinity, all numbers beyond the biggest finite bucket will report the same answer
],
)
def test_histogram_count_for_delta_service(
@@ -184,10 +212,7 @@ def test_histogram_count_for_delta_service(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
filter_expression='service = "web"',
)
@@ -196,11 +221,16 @@ def test_histogram_count_for_delta_service(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
assert (
len(result_values) == 60
) ## in delta, the value at 10:01 will also be reported
assert result_values[0]["value"] == zeroth_value
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert (
result_values[1]["value"] == first_value
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[-1]["value"] == last_value
@pytest.mark.parametrize(
"threshold, operator, zeroth_value, first_value, last_value",
[
@@ -245,10 +275,7 @@ def test_histogram_count_for_all_services(
metric_name,
"increase",
"count",
comparisonSpaceAggregationParam={
"threshold": threshold,
"operator": operator
},
comparisonSpaceAggregationParam={"threshold": threshold, "operator": operator},
## no services filter, this tests for multitemporality handling as well
)
@@ -257,11 +284,16 @@ def test_histogram_count_for_all_services(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert len(result_values) == 60 ## in delta, the value at 10:01 will also be reported
assert (
len(result_values) == 60
) ## in delta, the value at 10:01 will also be reported
assert result_values[0]["value"] == zeroth_value
assert result_values[1]["value"] == first_value ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert (
result_values[1]["value"] == first_value
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert result_values[-1]["value"] == last_value
def test_histogram_count_no_param(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
@@ -308,8 +340,26 @@ def test_histogram_count_no_param(
set(le_buckets.keys()) == expected_buckets
), f"Expected endpoints {expected_buckets}, got {set(le_buckets.keys())}"
first_values = {"1000": 33, "1500": 36, "2000": 39, "4000": 42, "5000": 45, "6000": 48, "8000": 51, "+Inf": 54}
last_values = {"1000": 207, "1500": 210, "2000": 213, "4000": 216, "5000": 219, "6000": 222, "8000": 225, "+Inf": 228}
first_values = {
"1000": 33,
"1500": 36,
"2000": 39,
"4000": 42,
"5000": 45,
"6000": 48,
"8000": 51,
"+Inf": 54,
}
last_values = {
"1000": 207,
"1500": 210,
"2000": 213,
"4000": 216,
"5000": 219,
"6000": 222,
"8000": 225,
"+Inf": 228,
}
for le, values in le_buckets.items():
assert len(values) == 60
@@ -318,5 +368,7 @@ def test_histogram_count_no_param(
v["value"] >= 0
), f"Count for {le} should not be negative: {v['value']}"
assert values[0]["value"] == 12345
assert values[1]["value"] == first_values[le] ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]
assert (
values[1]["value"] == first_values[le]
) ## to keep parallel to the cumulative test cases, first_value refers to the value at 10:02
assert values[-1]["value"] == last_values[le]

View File

@@ -1,4 +1,3 @@
import random
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
@@ -10,7 +9,6 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
build_builder_query,
get_all_series,
get_series_values,
make_query_request,
)
@@ -18,6 +16,7 @@ from fixtures.utils import get_testdata_file_path
FILE = get_testdata_file_path("gauge_data_1h.jsonl")
@pytest.mark.parametrize(
"time_agg, space_agg, service, num_elements, start_val, first_val, twentieth_min_val, after_twentieth_min_val",
[
@@ -50,7 +49,7 @@ def test_for_one_service(
start_val: float,
first_val: float,
twentieth_min_val: float,
after_twentieth_min_val: float ## web service has a gap of 10 mins after the 20th minute
after_twentieth_min_val: float, ## web service has a gap of 10 mins after the 20th minute
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
@@ -84,6 +83,7 @@ def test_for_one_service(
assert result_values[19]["value"] == twentieth_min_val
assert result_values[20]["value"] == after_twentieth_min_val
@pytest.mark.parametrize(
"time_agg, space_agg, start_val, first_val, twentieth_min_val, twenty_first_min_val, thirty_first_min_val",
[
@@ -105,8 +105,8 @@ def test_for_multiple_aggregations(
start_val: float,
first_val: float,
twentieth_min_val: float,
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
thirty_first_min_val: float
twenty_first_min_val: float, ## web service has a gap of 10 mins after the 20th minute
thirty_first_min_val: float,
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
@@ -138,4 +138,4 @@ def test_for_multiple_aggregations(
assert result_values[1]["value"] == first_val
assert result_values[19]["value"] == twentieth_min_val
assert result_values[20]["value"] == twenty_first_min_val
assert result_values[30]["value"] == thirty_first_min_val
assert result_values[30]["value"] == thirty_first_min_val

View File

@@ -53,7 +53,9 @@ def test_rate_with_steady_values_and_reset(
data = response.json()
result_values = sorted(get_series_values(data, "A"), key=lambda x: x["timestamp"])
assert len(result_values) == 60 ## total 61 minutes covered, and 30th minute is missing
assert (
len(result_values) == 60
) ## total 61 minutes covered, and 30th minute is missing
assert (
result_values[30]["value"] == 0.0333
) # reset happens and [30] is for 31st minute. 2/60 cuz delta divides by step interval
@@ -61,9 +63,7 @@ def test_rate_with_steady_values_and_reset(
result_values[31]["value"] == 0.133
) # i.e 8/60 i.e 31st to 32nd minute changes
count_of_steady_rate = sum(1 for v in result_values if v["value"] == 0.0833)
assert (
count_of_steady_rate == 58
) # 1 reset + 1 high rate are excluded
assert count_of_steady_rate == 58 # 1 reset + 1 high rate are excluded
# All rates should be non-negative (stale periods = 0 rate)
for v in result_values:
assert v["value"] >= 0, f"Rate should not be negative: {v['value']}"

View File

@@ -0,0 +1,109 @@
from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from typing import Callable, List
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.meter import MeterSample, make_meter_samples
from fixtures.querier import (
build_builder_query,
get_series_values,
make_query_request,
)
def test_query_range_cost_meter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[List[MeterSample]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "signoz_cost_test_query_range"
labels = {"service": "test-service", "environment": "production"}
samples = make_meter_samples(
metric_name,
labels,
now,
count=60,
base_value=100.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"sum",
"sum",
source="meter",
temporality="delta",
)
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
result_values = get_series_values(data, "A")
assert len(result_values) > 0, f"Expected non-empty results, got: {data}"
for val in result_values:
assert val["value"] >= 0, f"Expected non-negative value, got: {val['value']}"
def test_list_meter_metric_names(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_meter_samples: Callable[[List[MeterSample]], None],
) -> None:
now = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=65)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = "cost_test_list_metrics"
labels = {"service": "billing-service"}
samples = make_meter_samples(
metric_name,
labels,
now,
count=5,
base_value=50.0,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
insert_meter_samples(samples)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/metrics"),
params={
"start": start_ms,
"end": end_ms,
"limit": 100,
"searchText": "cost_test_list",
},
headers={"authorization": f"Bearer {token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
metrics = data.get("data", {}).get("metrics", [])
metric_names = [m["metricName"] for m in metrics]
assert (
metric_name in metric_names
), f"Expected {metric_name} in metric names, got: {metric_names}"