mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-31 10:10:36 +01:00
Compare commits
4 Commits
feat/gcp-c
...
monotonic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442d58bccc | ||
|
|
33a0cd043c | ||
|
|
1a6a693466 | ||
|
|
3e35d1ef64 |
@@ -9934,14 +9934,9 @@ paths:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists the services metadata for the specified cloud
|
||||
provider
|
||||
provider, without any account context.
|
||||
operationId: ListServicesMetadata
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
@@ -9991,14 +9986,10 @@ paths:
|
||||
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets a service for the specified cloud provider
|
||||
description: This endpoint gets a service definition for the specified cloud
|
||||
provider, without any account context.
|
||||
operationId: GetService
|
||||
parameters:
|
||||
- in: query
|
||||
name: cloud_integration_id
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: cloud_provider
|
||||
required: true
|
||||
|
||||
@@ -36,14 +36,12 @@ import type {
|
||||
GetConnectionCredentials200,
|
||||
GetConnectionCredentialsPathParameters,
|
||||
GetService200,
|
||||
GetServiceParams,
|
||||
GetServicePathParameters,
|
||||
ListAccountServicesMetadata200,
|
||||
ListAccountServicesMetadataPathParameters,
|
||||
ListAccounts200,
|
||||
ListAccountsPathParameters,
|
||||
ListServicesMetadata200,
|
||||
ListServicesMetadataParams,
|
||||
ListServicesMetadataPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateAccountPathParameters,
|
||||
@@ -1162,30 +1160,24 @@ export const invalidateGetConnectionCredentials = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the services metadata for the specified cloud provider
|
||||
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
|
||||
* @summary List services metadata
|
||||
*/
|
||||
export const listServicesMetadata = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListServicesMetadata200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryKey = (
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
export const getListServicesMetadataQueryKey = ({
|
||||
cloudProvider,
|
||||
}: ListServicesMetadataPathParameters) => {
|
||||
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
|
||||
};
|
||||
|
||||
export const getListServicesMetadataQueryOptions = <
|
||||
@@ -1193,7 +1185,6 @@ export const getListServicesMetadataQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1205,12 +1196,11 @@ export const getListServicesMetadataQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getListServicesMetadataQueryKey({ cloudProvider }, params);
|
||||
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
|
||||
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1238,7 +1228,6 @@ export function useListServicesMetadata<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listServicesMetadata>>,
|
||||
@@ -1249,7 +1238,6 @@ export function useListServicesMetadata<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListServicesMetadataQueryOptions(
|
||||
{ cloudProvider },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1266,11 +1254,10 @@ export function useListServicesMetadata<
|
||||
export const invalidateListServicesMetadata = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider }: ListServicesMetadataPathParameters,
|
||||
params?: ListServicesMetadataParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
|
||||
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1278,29 +1265,26 @@ export const invalidateListServicesMetadata = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint gets a service for the specified cloud provider
|
||||
* This endpoint gets a service definition for the specified cloud provider, without any account context.
|
||||
* @summary Get service
|
||||
*/
|
||||
export const getService = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetService200>({
|
||||
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceQueryKey = (
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
) => {
|
||||
export const getGetServiceQueryKey = ({
|
||||
cloudProvider,
|
||||
serviceId,
|
||||
}: GetServicePathParameters) => {
|
||||
return [
|
||||
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
@@ -1309,7 +1293,6 @@ export const getGetServiceQueryOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1321,12 +1304,11 @@ export const getGetServiceQueryOptions = <
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
|
||||
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
|
||||
signal,
|
||||
}) => getService({ cloudProvider, serviceId }, params, signal);
|
||||
}) => getService({ cloudProvider, serviceId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
@@ -1352,7 +1334,6 @@ export function useGetService<
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getService>>,
|
||||
@@ -1363,7 +1344,6 @@ export function useGetService<
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceQueryOptions(
|
||||
{ cloudProvider, serviceId },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -1380,11 +1360,10 @@ export function useGetService<
|
||||
export const invalidateGetService = async (
|
||||
queryClient: QueryClient,
|
||||
{ cloudProvider, serviceId }: GetServicePathParameters,
|
||||
params?: GetServiceParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
|
||||
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
@@ -10204,14 +10204,6 @@ export type GetConnectionCredentials200 = {
|
||||
export type ListServicesMetadataPathParameters = {
|
||||
cloudProvider: string;
|
||||
};
|
||||
export type ListServicesMetadataParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type ListServicesMetadata200 = {
|
||||
data: CloudintegrationtypesGettableServicesMetadataDTO;
|
||||
/**
|
||||
@@ -10224,14 +10216,6 @@ export type GetServicePathParameters = {
|
||||
cloudProvider: string;
|
||||
serviceId: string;
|
||||
};
|
||||
export type GetServiceParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
cloud_integration_id?: string;
|
||||
};
|
||||
|
||||
export type GetService200 = {
|
||||
data: CloudintegrationtypesServiceDTO;
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// ** Helpers
|
||||
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
@@ -389,11 +392,19 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
|
||||
export function toAttributeType(
|
||||
metricType: MetrictypesTypeDTO | undefined,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
if (!metricType) {
|
||||
return '';
|
||||
}
|
||||
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
|
||||
// Monotonicity carries meaning only for cumulative sums: a non-monotonic
|
||||
// cumulative sum is a gauge for all practical purposes. Delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if (
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative
|
||||
) {
|
||||
return ATTRIBUTE_TYPES.GAUGE;
|
||||
}
|
||||
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';
|
||||
|
||||
@@ -147,7 +147,6 @@ function ServiceDetails({
|
||||
cloudProvider: type,
|
||||
serviceId: serviceId || '',
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
query: {
|
||||
enabled: !!serviceId && !cloudAccountId,
|
||||
|
||||
@@ -39,9 +39,12 @@ function ServicesList({
|
||||
const {
|
||||
data: providerServicesMetadata,
|
||||
isLoading: isProviderServicesLoading,
|
||||
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
});
|
||||
} = useListServicesMetadata(
|
||||
{ cloudProvider: type },
|
||||
{
|
||||
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
|
||||
},
|
||||
);
|
||||
|
||||
const servicesMetadata = hasConnectedAccounts
|
||||
? accountServicesMetadata
|
||||
|
||||
@@ -33,6 +33,7 @@ function AllAttributes({
|
||||
metricName,
|
||||
metricType,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: AllAttributesProps): JSX.Element {
|
||||
@@ -71,6 +72,7 @@ function AllAttributes({
|
||||
groupBy,
|
||||
limit,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -89,7 +91,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const goToMetricsExploreWithAppliedAttribute = useCallback(
|
||||
@@ -101,6 +103,7 @@ function AllAttributes({
|
||||
undefined,
|
||||
undefined,
|
||||
isMonotonic,
|
||||
temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -120,7 +123,7 @@ function AllAttributes({
|
||||
[MetricsExplorerEventKeys.AttributeValue]: value,
|
||||
});
|
||||
},
|
||||
[metricName, metricType, isMonotonic, handleExplorerTabChange],
|
||||
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
const handleKeyMenuItemClick = useCallback(
|
||||
|
||||
@@ -86,6 +86,7 @@ function MetricDetails({
|
||||
undefined,
|
||||
undefined,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
);
|
||||
handleExplorerTabChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
@@ -108,6 +109,7 @@ function MetricDetails({
|
||||
handleExplorerTabChange,
|
||||
metadata?.type,
|
||||
metadata?.isMonotonic,
|
||||
metadata?.temporality,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,6 +198,7 @@ function MetricDetails({
|
||||
metricName={metricName}
|
||||
metricType={metadata?.type}
|
||||
isMonotonic={metadata?.isMonotonic}
|
||||
temporality={metadata?.temporality}
|
||||
minTime={minTime}
|
||||
maxTime={maxTime}
|
||||
/>
|
||||
|
||||
@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should treat a non-monotonic cumulative SUM as a gauge', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.cumulative,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.GAUGE,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
|
||||
});
|
||||
|
||||
it('should treat a non-monotonic delta SUM as a counter', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
MetrictypesTypeDTO.sum,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
MetrictypesTemporalityDTO.delta,
|
||||
);
|
||||
|
||||
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
|
||||
ATTRIBUTE_TYPES.SUM,
|
||||
);
|
||||
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
|
||||
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
|
||||
});
|
||||
|
||||
it('should create correct query for GAUGE metric type', () => {
|
||||
const query = getMetricDetailsQuery(
|
||||
TEST_METRIC_NAME,
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface AllAttributesProps {
|
||||
metricName: string;
|
||||
metricType: MetrictypesTypeDTO | undefined;
|
||||
isMonotonic?: boolean;
|
||||
temporality?: MetrictypesTemporalityDTO;
|
||||
minTime?: number;
|
||||
maxTime?: number;
|
||||
}
|
||||
|
||||
@@ -89,16 +89,21 @@ export function getMetricDetailsQuery(
|
||||
groupBy?: string,
|
||||
limit?: number,
|
||||
isMonotonic?: boolean,
|
||||
temporality?: MetrictypesTemporalityDTO,
|
||||
): Query {
|
||||
let timeAggregation;
|
||||
let spaceAggregation;
|
||||
let aggregateOperator;
|
||||
const isNonMonotonicSum =
|
||||
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
|
||||
// Monotonicity carries meaning only for cumulative sums; delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
const isNonMonotonicCumulativeSum =
|
||||
metricType === MetrictypesTypeDTO.sum &&
|
||||
isMonotonic === false &&
|
||||
temporality === MetrictypesTemporalityDTO.cumulative;
|
||||
|
||||
switch (metricType) {
|
||||
case MetrictypesTypeDTO.sum:
|
||||
if (isNonMonotonicSum) {
|
||||
if (isNonMonotonicCumulativeSum) {
|
||||
timeAggregation = 'avg';
|
||||
spaceAggregation = 'avg';
|
||||
aggregateOperator = 'avg';
|
||||
@@ -131,7 +136,7 @@ export function getMetricDetailsQuery(
|
||||
break;
|
||||
}
|
||||
|
||||
const attributeType = toAttributeType(metricType, isMonotonic);
|
||||
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
|
||||
|
||||
return {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@testing-library/react';
|
||||
import {
|
||||
MetricsexplorertypesListMetricDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
@@ -70,7 +71,7 @@ function makeMetric(
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: true,
|
||||
description: '',
|
||||
temporality: 'cumulative' as never,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
unit: '',
|
||||
...overrides,
|
||||
};
|
||||
@@ -393,12 +394,13 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic Sum metric is treated as Gauge', () => {
|
||||
it('non-monotonic cumulative Sum metric is treated as Gauge', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'active_connections',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: MetrictypesTemporalityDTO.cumulative,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -427,6 +429,36 @@ describe('selecting a metric type updates the aggregation options', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('non-monotonic delta Sum metric remains a counter with Rate/Increase options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
metricName: 'gcp_reject_connections_count',
|
||||
type: MetrictypesTypeDTO.sum,
|
||||
isMonotonic: false,
|
||||
temporality: MetrictypesTemporalityDTO.delta,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<MetricQueryHarness query={makeQuery()} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'gcp_reject_connections_count' },
|
||||
});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(getOptionLabels('time-agg-options')).toStrictEqual([
|
||||
'Rate',
|
||||
'Increase',
|
||||
]);
|
||||
expect(getOptionLabels('space-agg-options')).toStrictEqual([
|
||||
'Sum',
|
||||
'Avg',
|
||||
'Min',
|
||||
'Max',
|
||||
]);
|
||||
});
|
||||
|
||||
it('Histogram metric shows no time options and P50–P99 space options', () => {
|
||||
returnMetrics([
|
||||
makeMetric({
|
||||
|
||||
@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
|
||||
function getAttributeType(
|
||||
metric: MetricsexplorertypesListMetricDTO,
|
||||
): ATTRIBUTE_TYPES | '' {
|
||||
return toAttributeType(metric.type, metric.isMonotonic);
|
||||
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
|
||||
}
|
||||
|
||||
function createAutocompleteData(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getGroupContextMenuConfig } from '../contextConfig';
|
||||
|
||||
const GROUP_KEY = 'http.status_code';
|
||||
|
||||
const makeQuery = (dataType: string): Query =>
|
||||
({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
groupBy: [{ key: GROUP_KEY, dataType, type: 'attribute' }],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
}) as unknown as Query;
|
||||
|
||||
const clickedData: ClickedData = {
|
||||
column: { dataIndex: GROUP_KEY },
|
||||
record: { key: GROUP_KEY, timestamp: 0 },
|
||||
};
|
||||
|
||||
const renderGroupMenu = (query: Query): void => {
|
||||
const { items } = getGroupContextMenuConfig({
|
||||
query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
render(<div>{items}</div>);
|
||||
};
|
||||
|
||||
describe('getGroupContextMenuConfig', () => {
|
||||
// A `number` group-by used to throw: it isn't a key of QUERY_BUILDER_OPERATORS_BY_TYPES.
|
||||
it('renders comparison operators for a `number` group-by column', () => {
|
||||
renderGroupMenu(makeQuery('number'));
|
||||
|
||||
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is less than')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only equality operators for a string group-by column', () => {
|
||||
renderGroupMenu(makeQuery(DataTypes.String));
|
||||
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is not this')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Is greater than or equal to'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to equality operators when the column has no known data type', () => {
|
||||
renderGroupMenu(makeQuery(''));
|
||||
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Is greater than or equal to'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns no items for a non-table panel', () => {
|
||||
const config = getGroupContextMenuConfig({
|
||||
query: makeQuery('number'),
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
|
||||
expect(config.items).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
getOperatorsByDataType,
|
||||
getQueryData,
|
||||
getViewQuery,
|
||||
isNumberDataType,
|
||||
isValidQueryName,
|
||||
} from '../drilldownUtils';
|
||||
import { METRIC_TO_LOGS_TRACES_MAPPINGS } from '../metricsCorrelationUtils';
|
||||
@@ -687,4 +690,41 @@ describe('drilldownUtils', () => {
|
||||
expect(expr).toContain(`name = 'GET /api'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperatorsByDataType', () => {
|
||||
it('gives numeric operators for the V5 `number` data type', () => {
|
||||
const operators = getOperatorsByDataType('number');
|
||||
expect(operators).toContain('>=');
|
||||
expect(operators).toContain('<');
|
||||
expect(operators).not.toContain('LIKE');
|
||||
});
|
||||
|
||||
it('gives numeric operators for the V3 int64 / float64 data types', () => {
|
||||
expect(getOperatorsByDataType(DataTypes.Int64)).toContain('>=');
|
||||
expect(getOperatorsByDataType(DataTypes.Float64)).toContain('>=');
|
||||
});
|
||||
|
||||
it('falls back to the string operators for unmapped, empty and missing types', () => {
|
||||
const stringOperators = getOperatorsByDataType(DataTypes.String);
|
||||
expect(getOperatorsByDataType('[]string')).toStrictEqual(stringOperators);
|
||||
expect(getOperatorsByDataType('')).toStrictEqual(stringOperators);
|
||||
expect(getOperatorsByDataType(undefined)).toStrictEqual(stringOperators);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNumberDataType', () => {
|
||||
it.each([DataTypes.Int64, DataTypes.Float64, 'number' as DataTypes])(
|
||||
'treats %s as numeric',
|
||||
(dataType) => {
|
||||
expect(isNumberDataType(dataType)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([DataTypes.String, DataTypes.bool, DataTypes.EMPTY, undefined])(
|
||||
'does not treat %s as numeric',
|
||||
(dataType) => {
|
||||
expect(isNumberDataType(dataType)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fromPerses,
|
||||
toPerses,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { ClickedData } from 'periscope/components/ContextMenu';
|
||||
|
||||
import { getGroupContextMenuConfig } from '../contextConfig';
|
||||
import {
|
||||
addFilterToQuery,
|
||||
getBaseMeta,
|
||||
isNumberDataType,
|
||||
} from '../drilldownUtils';
|
||||
|
||||
const GROUP_KEY = 'panja.pinger.status_code';
|
||||
|
||||
/**
|
||||
* A saved V2 table panel grouped by a numeric attribute. The backend rewrites `float64` to
|
||||
* `number` when it stores the panel, so `number` is what a reload actually carries.
|
||||
*/
|
||||
const savedPanelQueries = [
|
||||
{
|
||||
kind: 'signoz/scalar',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [{ metricName: 'probe_checks', spaceAggregation: 'sum' }],
|
||||
groupBy: [
|
||||
{
|
||||
name: GROUP_KEY,
|
||||
fieldDataType: 'number',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
],
|
||||
filter: { expression: '' },
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
describe('drilldown on a numeric group-by column (V2 table panel)', () => {
|
||||
const v1Query = fromPerses(savedPanelQueries, PANEL_TYPES.TABLE);
|
||||
const groupByDataType = getBaseMeta(v1Query, GROUP_KEY)?.dataType;
|
||||
|
||||
it('sees the `number` type the panel was stored with', () => {
|
||||
expect(groupByDataType).toBe('number');
|
||||
});
|
||||
|
||||
it('offers the numeric operators instead of throwing', () => {
|
||||
const clickedData: ClickedData = {
|
||||
column: { dataIndex: GROUP_KEY },
|
||||
record: { key: GROUP_KEY, timestamp: 0 },
|
||||
};
|
||||
const { items } = getGroupContextMenuConfig({
|
||||
query: v1Query,
|
||||
clickedData,
|
||||
panelType: PANEL_TYPES.TABLE,
|
||||
onColumnClick: jest.fn(),
|
||||
});
|
||||
render(<div>{items}</div>);
|
||||
|
||||
expect(screen.getByText(`Filter by ${GROUP_KEY}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Is this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Is greater than or equal to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters on an unquoted number', () => {
|
||||
// What the filter hooks branch on before coercing the clicked value.
|
||||
expect(isNumberDataType(groupByDataType)).toBe(true);
|
||||
|
||||
const refined = addFilterToQuery(v1Query, [
|
||||
{ filterKey: GROUP_KEY, filterValue: 200, operator: '=' },
|
||||
]);
|
||||
expect(refined.builder.queryData[0].filter?.expression).toBe(
|
||||
`${GROUP_KEY} = 200`,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the stored query shape untouched on the way back out', () => {
|
||||
const [envelope] = toPerses(v1Query, PANEL_TYPES.TABLE);
|
||||
const composite = envelope.spec.plugin
|
||||
.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
const [query] = composite.queries ?? [];
|
||||
// The generated envelope union doesn't discriminate `spec` by `type`.
|
||||
const spec = query?.spec as Querybuildertypesv5BuilderQuerySpecDTO;
|
||||
|
||||
expect(spec.groupBy?.[0]).toMatchObject({
|
||||
name: GROUP_KEY,
|
||||
fieldDataType: 'number',
|
||||
fieldContext: 'attribute',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
PANEL_TYPES,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getBaseMeta } from './drilldownUtils';
|
||||
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
|
||||
import { SUPPORTED_OPERATORS } from './menuOptions';
|
||||
import { BreakoutAttributeType } from './types';
|
||||
|
||||
@@ -49,15 +46,9 @@ export function getGroupContextMenuConfig({
|
||||
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
|
||||
const filterKey = clickedData?.column?.dataIndex;
|
||||
|
||||
const filterDataType =
|
||||
getBaseMeta(query, filterKey as string)?.dataType || 'string';
|
||||
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
|
||||
|
||||
const operators =
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
];
|
||||
|
||||
const filterOperators = operators.filter(
|
||||
const filterOperators = getOperatorsByDataType(filterDataType).filter(
|
||||
(operator) => SUPPORTED_OPERATORS[operator],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { isApmMetric } from 'container/PanelWrapper/utils';
|
||||
@@ -54,13 +55,44 @@ export const getRoute = (key: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A group-by's `dataType` comes from the field metadata, which reports a numeric as `number`
|
||||
* (V5, what a saved V2 panel carries) or as `int64` / `float64` (V3) — all three are numeric.
|
||||
*/
|
||||
const NUMERIC_DATA_TYPES: string[] = [
|
||||
DataTypes.Int64,
|
||||
DataTypes.Float64,
|
||||
'number',
|
||||
];
|
||||
|
||||
export const isNumberDataType = (dataType: DataTypes | undefined): boolean => {
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
|
||||
return NUMERIC_DATA_TYPES.includes(dataType);
|
||||
};
|
||||
|
||||
/**
|
||||
* `QUERY_BUILDER_OPERATORS_BY_TYPES` is keyed by the V3 data types, so the `number` a saved V2
|
||||
* panel carries misses it — and a raw lookup returns `undefined`, which throws on the caller's
|
||||
* `.filter`. Resolve through this table and fall back to the string set.
|
||||
*/
|
||||
const OPERATOR_DATA_TYPES: Record<
|
||||
string,
|
||||
keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
> = {
|
||||
[DataTypes.String]: 'string',
|
||||
[DataTypes.Int64]: 'int64',
|
||||
[DataTypes.Float64]: 'float64',
|
||||
[DataTypes.bool]: 'bool',
|
||||
number: 'float64',
|
||||
};
|
||||
|
||||
export const getOperatorsByDataType = (dataType?: string): string[] =>
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
OPERATOR_DATA_TYPES[dataType ?? ''] ?? 'string'
|
||||
];
|
||||
|
||||
export interface FilterData {
|
||||
filterKey: string;
|
||||
filterValue: string | number;
|
||||
|
||||
@@ -174,11 +174,13 @@ const baseProps = {
|
||||
function setup(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
|
||||
draftOverrides?: { isSpecDirty?: boolean },
|
||||
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
|
||||
): void {
|
||||
// The live draft can diverge from the seed `panel`; default to the seed.
|
||||
const draftPanel = draftOverrides?.draft ?? panel;
|
||||
mockUseDraft.mockReturnValue({
|
||||
draft: panel,
|
||||
spec: panel.spec,
|
||||
draft: draftPanel,
|
||||
spec: draftPanel.spec,
|
||||
setSpec: mockSetSpec,
|
||||
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
|
||||
});
|
||||
@@ -271,6 +273,23 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a query-less new panel clean after the staged-query sync seeds its draft (regression)', () => {
|
||||
// Staged-query sync populated the draft with the seed; dirty must read the seed
|
||||
// `panel` (query-less), not the draft, else an untouched new panel reads dirty.
|
||||
const committedDraft = makePanel('signoz/TimeSeriesPanel', [
|
||||
{ spec: { plugin: { kind: 'signoz/CompositeQuery', spec: {} } } },
|
||||
]);
|
||||
setup(
|
||||
makePanel('signoz/TimeSeriesPanel'),
|
||||
{ isNew: true },
|
||||
{ draft: committedDraft },
|
||||
);
|
||||
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
|
||||
const seededQuery = {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -89,6 +90,33 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[DataSource.METRICS, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.LOGS, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.TRACES, PANEL_TYPES.TIME_SERIES],
|
||||
[DataSource.LOGS, PANEL_TYPES.LIST],
|
||||
])(
|
||||
'a NEW %s panel (%s, no savedQueries) is NOT query-dirty on mount',
|
||||
async (ds, pt) => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel([]),
|
||||
panelType: pt,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
alwaysSerializeQuery: true,
|
||||
signal: ds as unknown as TelemetrytypesSignalDTO,
|
||||
// savedQueries omitted — new panel.
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
|
||||
// An older saved query carries only a few fields; the builder re-emits many more
|
||||
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
|
||||
|
||||
@@ -159,11 +159,12 @@ function PanelEditorContainer({
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// A new panel is savable once it has a query to run — List auto-seeds one; other
|
||||
// kinds open query-less, so there's nothing to save until the user builds one.
|
||||
// New panels are savable once seeded with a query (List auto-seeds one). Read the
|
||||
// seed `panel`, not the live `draft` — the staged-query sync commits the seed into
|
||||
// the draft on open, which would falsely dirty an untouched query-less new panel.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
|
||||
@@ -136,9 +136,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "ListServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "List services metadata",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider",
|
||||
Description: "This endpoint lists the services metadata for the specified cloud provider, without any account context.",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.ListServicesMetadataParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.GettableServicesMetadata),
|
||||
ResponseContentType: "application/json",
|
||||
@@ -177,9 +176,8 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
ID: "GetService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
Summary: "Get service",
|
||||
Description: "This endpoint gets a service for the specified cloud provider",
|
||||
Description: "This endpoint gets a service definition for the specified cloud provider, without any account context.",
|
||||
Request: nil,
|
||||
RequestQuery: new(citypes.GetServiceParams),
|
||||
RequestContentType: "",
|
||||
Response: new(citypes.Service),
|
||||
ResponseContentType: "application/json",
|
||||
|
||||
@@ -251,23 +251,7 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
queryParams := new(cloudintegrationtypes.ListServicesMetadataParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
// check if integration account exists and is not removed.
|
||||
if !queryParams.CloudIntegrationID.IsZero() {
|
||||
_, err := handler.module.GetConnectedAccount(ctx, orgID, queryParams.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
services, err := handler.module.ListServicesMetadata(ctx, orgID, provider, queryParams.CloudIntegrationID)
|
||||
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, valuer.UUID{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -336,22 +320,7 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
queryParams := new(cloudintegrationtypes.GetServiceParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), queryParams); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
// check if integration account exists and is not removed.
|
||||
if !queryParams.CloudIntegrationID.IsZero() {
|
||||
_, err := handler.module.GetConnectedAccount(ctx, valuer.MustNewUUID(claims.OrgID), queryParams.CloudIntegrationID, provider)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, queryParams.CloudIntegrationID)
|
||||
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, valuer.UUID{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -2359,7 +2359,10 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
|
||||
if temporality != metrictypes.Unknown {
|
||||
temporalities[metricName] = append(temporalities[metricName], temporality)
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
// Monotonicity carries meaning only for cumulative sums: a non-monotonic
|
||||
// cumulative sum is a gauge for all practical purposes. Delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
types[metricName] = metricType
|
||||
@@ -2412,7 +2415,9 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
|
||||
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
|
||||
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
|
||||
}
|
||||
if metricType == metrictypes.SumType && !isMonotonic {
|
||||
// Non-monotonic cumulative sums are treated as gauges; delta sums are
|
||||
// counters regardless of monotonicity.
|
||||
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
|
||||
metricType = metrictypes.GaugeType
|
||||
}
|
||||
temporalities[metricName] = temporality
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrytraces"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -109,3 +111,85 @@ func TestGetFirstSeenFromMetricMetadata(t *testing.T) {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sum metrics are classified as counters unless they are non-monotonic AND
|
||||
// cumulative; monotonicity carries no meaning for delta sums, so a
|
||||
// non-monotonic delta sum must remain a Sum (counter).
|
||||
func TestFetchTemporalityAndTypeMultiSumClassification(t *testing.T) {
|
||||
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, ®exMatcher{})
|
||||
mock := mockTelemetryStore.Mock()
|
||||
|
||||
metadata := NewTelemetryMetaStore(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockTelemetryStore,
|
||||
telemetrytraces.DBName,
|
||||
telemetrytraces.TagAttributesV2TableName,
|
||||
telemetrytraces.SpanAttributesKeysTblName,
|
||||
telemetrytraces.SpanIndexV3TableName,
|
||||
telemetrymetrics.DBName,
|
||||
telemetrymetrics.AttributesMetadataTableName,
|
||||
telemetrymeter.DBName,
|
||||
telemetrymeter.SamplesAgg1dTableName,
|
||||
telemetrylogs.DBName,
|
||||
telemetrylogs.LogsV2TableName,
|
||||
telemetrylogs.TagAttributesV2TableName,
|
||||
telemetrylogs.LogAttributeKeysTblName,
|
||||
telemetrylogs.LogResourceKeysTblName,
|
||||
telemetryaudit.DBName,
|
||||
telemetryaudit.AuditLogsTableName,
|
||||
telemetryaudit.TagAttributesTableName,
|
||||
telemetryaudit.LogAttributeKeysTblName,
|
||||
telemetryaudit.LogResourceKeysTblName,
|
||||
DBName,
|
||||
AttributesMetadataTableName,
|
||||
ColumnEvolutionMetadataTableName,
|
||||
flaggertest.New(t),
|
||||
)
|
||||
|
||||
metricNames := []string{
|
||||
"delta.nonmono.sum",
|
||||
"delta.mono.sum",
|
||||
"cumulative.nonmono.sum",
|
||||
"cumulative.mono.sum",
|
||||
}
|
||||
|
||||
metadataCols := []cmock.ColumnType{
|
||||
{Name: "metric_name", Type: "String"},
|
||||
{Name: "temporality", Type: "String"},
|
||||
{Name: "type", Type: "String"},
|
||||
{Name: "is_monotonic", Type: "Bool"},
|
||||
}
|
||||
|
||||
mock.ExpectQuery(`SELECT metric_name, temporality, any\(type\) AS type, argMax\(is_monotonic, unix_milli\) as is_monotonic FROM signoz_metrics\.`).
|
||||
WithArgs(nil, nil, nil).
|
||||
WillReturnRows(cmock.NewRows(metadataCols, [][]any{
|
||||
{"delta.nonmono.sum", metrictypes.Delta, metrictypes.SumType, false},
|
||||
{"delta.mono.sum", metrictypes.Delta, metrictypes.SumType, true},
|
||||
{"cumulative.nonmono.sum", metrictypes.Cumulative, metrictypes.SumType, false},
|
||||
{"cumulative.mono.sum", metrictypes.Cumulative, metrictypes.SumType, true},
|
||||
}))
|
||||
mock.ExpectQuery(`SELECT metric_name, argMax\(temporality, unix_milli\) as temporality, any\(type\) AS type, argMax\(is_monotonic, unix_milli\) as is_monotonic FROM signoz_meter\.`).
|
||||
WithArgs(nil).
|
||||
WillReturnRows(cmock.NewRows(metadataCols, [][]any{}))
|
||||
|
||||
temporalities, types, _, err := metadata.FetchTemporalityAndTypeMulti(
|
||||
context.Background(),
|
||||
valuer.GenerateUUID(),
|
||||
1700000000000,
|
||||
1700003600000,
|
||||
metricNames...,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, metrictypes.SumType, types["delta.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.SumType, types["delta.mono.sum"])
|
||||
assert.Equal(t, metrictypes.GaugeType, types["cumulative.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.SumType, types["cumulative.mono.sum"])
|
||||
|
||||
assert.Equal(t, metrictypes.Delta, temporalities["delta.nonmono.sum"])
|
||||
assert.Equal(t, metrictypes.Cumulative, temporalities["cumulative.nonmono.sum"])
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("there were unfulfilled expectations: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ type GettableServicesMetadata struct {
|
||||
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type ListServicesMetadataParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
// Service represents a cloud integration service with its definition,
|
||||
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
|
||||
type Service struct {
|
||||
@@ -63,10 +59,6 @@ type ServiceAssets struct {
|
||||
Dashboards []*ServiceDashboard `json:"dashboards" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type GetServiceParams struct {
|
||||
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
|
||||
}
|
||||
|
||||
type UpdatableService struct {
|
||||
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ def test_list_services_without_account(
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""List available services without specifying a cloud_integration_id."""
|
||||
"""List the cloud provider's supported services"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
@@ -54,38 +54,6 @@ def test_list_services_without_account(
|
||||
assert "enabled" in service, "Service should have 'enabled' field"
|
||||
|
||||
|
||||
def test_list_services_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""List services filtered to a specific account — all disabled by default."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert "services" in data, "Response should contain 'services' field"
|
||||
assert len(data["services"]) > 0, "services list should be non-empty"
|
||||
|
||||
for svc in data["services"]:
|
||||
assert "enabled" in svc, "Each service should have 'enabled' field"
|
||||
assert svc["enabled"] is False, f"Service {svc['id']} should be disabled before any config is set"
|
||||
|
||||
|
||||
EC2_SERVICE_ID = "ec2"
|
||||
|
||||
|
||||
@@ -154,34 +122,6 @@ def test_get_service_details_without_account(
|
||||
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
|
||||
|
||||
|
||||
def test_get_service_details_with_account(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Get service details with account context — cloudIntegrationService is null before first UpdateService."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
account_id = account["id"]
|
||||
|
||||
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
|
||||
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
|
||||
|
||||
data = response.json()["data"]
|
||||
assert data["id"] == SERVICE_ID
|
||||
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any service config is set"
|
||||
|
||||
|
||||
def test_get_account_service(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
@@ -252,7 +192,7 @@ def test_update_service_config(
|
||||
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -302,7 +242,7 @@ def test_update_service_config_disable(
|
||||
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
|
||||
|
||||
get_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -355,7 +295,7 @@ def test_list_services_account_removed(
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""List services with a cloud_integration_id for a deleted account returns 404."""
|
||||
"""List services for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
@@ -372,7 +312,7 @@ def test_list_services_account_removed(
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -386,7 +326,7 @@ def test_get_service_details_account_removed(
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Get service details with a cloud_integration_id for a deleted account returns 404."""
|
||||
"""Get service details for a deleted account returns 404."""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
|
||||
@@ -403,7 +343,7 @@ def test_get_service_details_account_removed(
|
||||
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -468,7 +408,7 @@ def test_enable_metrics_provisions_dashboards(
|
||||
|
||||
# Assertion 1: GetService returns provisioned dashboard UUIDs
|
||||
get_svc_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -533,7 +473,7 @@ def test_disable_metrics_deprovisions_dashboards(
|
||||
|
||||
# Capture the provisioned dashboard IDs before disabling
|
||||
get_svc_response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
@@ -552,7 +492,7 @@ def test_disable_metrics_deprovisions_dashboards(
|
||||
|
||||
# Assertion 1: GetService no longer returns UUID dashboard IDs
|
||||
get_svc_after = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}?cloud_integration_id={account_id}"),
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user