mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-30 01:30:39 +01:00
Compare commits
9 Commits
nv/str-equ
...
monotonic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
442d58bccc | ||
|
|
33a0cd043c | ||
|
|
1a6a693466 | ||
|
|
3e35d1ef64 | ||
|
|
1483fbd2c5 | ||
|
|
1255637e25 | ||
|
|
38639847be | ||
|
|
31cb4d7520 | ||
|
|
5ede27e8fb |
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -380,4 +380,88 @@ describe('convertV5ResponseToLegacy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw logs body: extract lone `message` field', () => {
|
||||
function makeRawResult(
|
||||
rows: Array<{ timestamp: string; data: Record<string, any> }>,
|
||||
type: 'raw' | 'trace' = 'raw',
|
||||
): ReturnType<typeof convertV5ResponseToLegacy> {
|
||||
const v5Data = {
|
||||
type,
|
||||
data: { results: [{ queryName: 'A', rows }] },
|
||||
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
|
||||
const params = makeBaseParams(type as RequestType, [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: type === 'trace' ? 'traces' : 'logs',
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const input: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5> =
|
||||
makeBaseSuccess({ data: v5Data }, params);
|
||||
|
||||
return convertV5ResponseToLegacy(input, { A: 'A' }, false);
|
||||
}
|
||||
|
||||
it('unwraps body when it is an object with only a message field', () => {
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: 'hello' } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe('hello');
|
||||
});
|
||||
|
||||
it('leaves body unchanged when the object has keys besides message', () => {
|
||||
const body = { message: 'hello', level: 'INFO' };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toStrictEqual(
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a string body unchanged (use_json_body off)', () => {
|
||||
const result = makeRawResult([
|
||||
{
|
||||
timestamp: '2026-07-21T00:00:00Z',
|
||||
data: { body: '{"message":"hello"}' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
'{"message":"hello"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('stringifies the nested object when message is an object', () => {
|
||||
const nested = { a: 1, b: 2 };
|
||||
const result = makeRawResult([
|
||||
{ timestamp: '2026-07-21T00:00:00Z', data: { body: { message: nested } } },
|
||||
]);
|
||||
|
||||
expect(result.payload.data.result[0].list?.[0]?.data?.body).toBe(
|
||||
JSON.stringify(nested),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a body key to rows without a body (traces)', () => {
|
||||
const result = makeRawResult(
|
||||
[{ timestamp: '2026-07-21T00:00:00Z', data: { name: 'span-1' } }],
|
||||
'trace',
|
||||
);
|
||||
|
||||
const data = (result.payload.data.result[0].list?.[0] as any)?.data ?? {};
|
||||
expect('body' in data).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,6 +273,19 @@ function convertScalarWithFormatForWeb(
|
||||
});
|
||||
}
|
||||
|
||||
function extractOnlyMessageBody(body: unknown): unknown {
|
||||
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
|
||||
if (isJsonBody) {
|
||||
const keys = Object.keys(body);
|
||||
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
|
||||
if (hasOnlyMessageKey) {
|
||||
const { message } = body as { message: unknown };
|
||||
return typeof message === 'string' ? message : JSON.stringify(message);
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts V5 RawData to legacy format
|
||||
*/
|
||||
@@ -285,14 +298,22 @@ function convertRawData(
|
||||
queryName: rawData.queryName,
|
||||
legend: legendMap[rawData.queryName] || rawData.queryName,
|
||||
series: null,
|
||||
list: rawData.rows?.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
data: {
|
||||
list: rawData.rows?.map((row) => {
|
||||
const data = {
|
||||
// Map raw data to ILog structure - spread row.data first to include all properties
|
||||
...row.data,
|
||||
date: row.timestamp,
|
||||
} as any,
|
||||
})),
|
||||
} as any;
|
||||
|
||||
if ('body' in row.data) {
|
||||
data.body = extractOnlyMessageBody(row.data.body);
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: row.timestamp,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
nextCursor: rawData.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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] || '';
|
||||
|
||||
@@ -167,6 +167,56 @@ describe('getAutoContexts', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns panel edit context on the V2 panel editor', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const panelId = 'panel-abc';
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
dashboardId,
|
||||
).replace(':panelId', panelId);
|
||||
|
||||
const contexts = getAutoContexts(pathname, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: panelId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns new panel context on the unsaved new-panel editor', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
dashboardId,
|
||||
).replace(':panelId', 'new');
|
||||
const startTime = '1700000000000';
|
||||
const endTime = '1700003600000';
|
||||
|
||||
const contexts = getAutoContexts(
|
||||
pathname,
|
||||
`?panelKind=TimeSeries&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`,
|
||||
);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_create',
|
||||
timeRange: { start: Number(startTime), end: Number(endTime) },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array on alert overview without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');
|
||||
|
||||
|
||||
@@ -17,6 +17,28 @@ describe('resolvePageType', () => {
|
||||
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.dashboard_detail);
|
||||
});
|
||||
|
||||
it('returns panel_edit on the V2 panel editor', () => {
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dash-123',
|
||||
).replace(':panelId', 'panel-abc');
|
||||
|
||||
expect(resolvePageType(pathname, '')).toBe(PageTypeDTO.panel_edit);
|
||||
});
|
||||
|
||||
// `panel_create` has no PageTypeDTO counterpart yet, so it reports
|
||||
// `panel_edit` rather than degrading to `other`.
|
||||
it('returns panel_edit on the unsaved new-panel editor', () => {
|
||||
const pathname = ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dash-123',
|
||||
).replace(':panelId', 'new');
|
||||
|
||||
expect(resolvePageType(pathname, '?panelKind=TimeSeries')).toBe(
|
||||
PageTypeDTO.panel_edit,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns alerts_triggered on alert history without ruleId', () => {
|
||||
expect(resolvePageType(ROUTES.ALERT_HISTORY, '')).toBe(
|
||||
PageTypeDTO.alerts_triggered,
|
||||
|
||||
@@ -16,17 +16,22 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import type { UploadFile } from 'antd';
|
||||
import getSessionStorage from 'api/browser/sessionstorage/get';
|
||||
import setSessionStorage from 'api/browser/sessionstorage/set';
|
||||
import {
|
||||
getListDashboardsForUserV2QueryKey,
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
import type { SuccessResponseV2 } from 'types/api';
|
||||
import type { Dashboard } from 'types/api/dashboard/getAll';
|
||||
// eslint-disable-next-line
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -100,6 +105,8 @@ function autoContextLabel(ctx: MessageContext): string {
|
||||
return 'Current dashboard';
|
||||
case 'panel_edit':
|
||||
return 'Editing panel';
|
||||
case 'panel_create':
|
||||
return 'New panel';
|
||||
case 'panel_fullscreen':
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
@@ -164,6 +171,18 @@ const TEXTAREA_MAX_HEIGHT_PX = 200;
|
||||
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
|
||||
/** sessionStorage key for the "voice input failed this tab" flag. */
|
||||
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
/**
|
||||
* The picker filters client-side, so it pulls one large page instead of
|
||||
* paginating. Shared with `getQueryData` below — the params are part of the
|
||||
* generated query key, so both sides must use the same object.
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
return dashboard.spec.display?.name || dashboard.name || 'Untitled';
|
||||
}
|
||||
|
||||
interface SelectedContextItem {
|
||||
category: ContextCategory;
|
||||
@@ -716,9 +735,11 @@ export default function ChatInput({
|
||||
data: dashboardsResponse,
|
||||
isLoading: isDashboardsLoading,
|
||||
isError: isDashboardsError,
|
||||
} = useGetAllDashboard({
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -765,12 +786,12 @@ export default function ChatInput({
|
||||
return ctx.resourceId;
|
||||
}
|
||||
if (ctx.type === 'dashboard' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
|
||||
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
|
||||
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
|
||||
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
|
||||
);
|
||||
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash?.data.title) {
|
||||
return dash.data.title;
|
||||
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
|
||||
if (dash) {
|
||||
return dashboardTitle(dash);
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
@@ -800,9 +821,9 @@ export default function ChatInput({
|
||||
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
|
||||
{
|
||||
Dashboards:
|
||||
dashboardsResponse?.data?.map((dashboard) => ({
|
||||
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
|
||||
id: dashboard.id,
|
||||
value: dashboard.data.title ?? 'Untitled',
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
|
||||
@@ -2,12 +2,13 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
// The prefill flow only depends on the context-picker data hooks resolving to
|
||||
// empty lists (so the empty state renders) — mock them to skip real fetches.
|
||||
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
|
||||
useGetAllDashboard: (): unknown => ({
|
||||
data: [],
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AlertListTabs } from 'pages/AlertList/types';
|
||||
import { NEW_PANEL_ID } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -30,22 +31,23 @@ export function getAutoContexts(
|
||||
|
||||
// ── Dashboards ────────────────────────────────────────────────────────────
|
||||
|
||||
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
|
||||
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
|
||||
// Panel editor (V2). `panel/new` has no widget id yet and the schema requires
|
||||
// a non-empty `panel_edit.widgetId`, so it reports `panel_create` instead.
|
||||
const panelEditorMatch = matchPath<{ dashboardId: string; panelId: string }>(
|
||||
pathname,
|
||||
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
|
||||
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
|
||||
);
|
||||
if (widgetMatch) {
|
||||
if (panelEditorMatch) {
|
||||
const { dashboardId, panelId } = panelEditorMatch.params;
|
||||
const isNewPanel = panelId === NEW_PANEL_ID;
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: widgetMatch.params.dashboardId,
|
||||
metadata: {
|
||||
page: 'panel_edit',
|
||||
widgetId: widgetMatch.params.widgetId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
resourceId: dashboardId,
|
||||
metadata: isNewPanel
|
||||
? { page: 'panel_create', ...sharedMetadata }
|
||||
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ const PAGE_METADATA_TO_DTO: Record<string, PageTypeDTO> = {
|
||||
dashboard_detail: PageTypeDTO.dashboard_detail,
|
||||
dashboard_list: PageTypeDTO.dashboard_list,
|
||||
panel_edit: PageTypeDTO.panel_edit,
|
||||
// There is no panel_create, so sending panel_edit temporarily
|
||||
panel_create: PageTypeDTO.panel_edit,
|
||||
panel_fullscreen: PageTypeDTO.panel_fullscreen,
|
||||
logs_explorer: PageTypeDTO.logs_explorer,
|
||||
trace_detail: PageTypeDTO.trace_detail,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
compareTableColumnValues,
|
||||
RowData,
|
||||
} from '../createTableColumnsFromQuery';
|
||||
|
||||
// Builds a minimal RowData row. Values are intentionally loosely typed because
|
||||
// real query responses can put objects/arrays into cells despite RowData's
|
||||
// declared `string | number` index signature (that mismatch is the bug under test).
|
||||
const row = (value: unknown, dataIndex = 'col'): RowData =>
|
||||
({
|
||||
timestamp: 0,
|
||||
key: 'k',
|
||||
[dataIndex]: value,
|
||||
}) as unknown as RowData;
|
||||
|
||||
describe('compareTableColumnValues', () => {
|
||||
it('sorts numerically when both cells are numbers', () => {
|
||||
expect(compareTableColumnValues(row(1), row(2), 'col')).toBeLessThan(0);
|
||||
expect(compareTableColumnValues(row(2), row(1), 'col')).toBeGreaterThan(0);
|
||||
expect(compareTableColumnValues(row(5), row(5), 'col')).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts numeric-looking strings numerically, not lexically', () => {
|
||||
// "10" vs "9": numeric branch => 10 - 9 > 0. A string compare would be < 0.
|
||||
expect(compareTableColumnValues(row('10'), row('9'), 'col')).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the `<dataIndex>_without_unit` value for numeric comparison', () => {
|
||||
const a = row('2 ms');
|
||||
const b = row('10 ms');
|
||||
a.col_without_unit = 2;
|
||||
b.col_without_unit = 10;
|
||||
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('falls back to locale string compare for non-numeric strings', () => {
|
||||
expect(compareTableColumnValues(row('abc'), row('abd'), 'col')).toBe(
|
||||
'abc'.localeCompare('abd'),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats null/undefined cells as empty string (no "null"/"undefined")', () => {
|
||||
// Empty string sorts before a real word, and two empties are equal.
|
||||
expect(compareTableColumnValues(row(null), row('a'), 'col')).toBeLessThan(0);
|
||||
expect(compareTableColumnValues(row(undefined), row(undefined), 'col')).toBe(
|
||||
0,
|
||||
);
|
||||
// If null coerced to the literal "null", this would sort after "a".
|
||||
expect(
|
||||
compareTableColumnValues(row(null), row('a'), 'col'),
|
||||
).not.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not throw when a cell value is an object', () => {
|
||||
const a = row({ foo: 'bar' });
|
||||
const b = row({ foo: 'baz' });
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
|
||||
});
|
||||
|
||||
it('does not throw when a cell value is an array', () => {
|
||||
const a = row([1, 2]);
|
||||
const b = row([3, 4]);
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
// String([1,2]) === "1,2" < String([3,4]) === "3,4"
|
||||
expect(compareTableColumnValues(a, b, 'col')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('does not throw when one cell is numeric and the other is an object', () => {
|
||||
const a = row(42);
|
||||
const b = row({ foo: 'bar' });
|
||||
expect(() => compareTableColumnValues(a, b, 'col')).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(a, b, 'col')).toBe('number');
|
||||
});
|
||||
|
||||
it('does not throw for a number cell compared against an "N/A" cell', () => {
|
||||
// http.status_code column: [null, "200", ...] -> ["N/A", 200, ...]
|
||||
const numberCell = row(200); // numeric string "200" becomes the number 200
|
||||
const naCell = row('N/A'); // null becomes the string "N/A"
|
||||
|
||||
// Both orderings — antd's sorter compares pairs in both directions.
|
||||
expect(() =>
|
||||
compareTableColumnValues(numberCell, naCell, 'col'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
compareTableColumnValues(naCell, numberCell, 'col'),
|
||||
).not.toThrow();
|
||||
expect(typeof compareTableColumnValues(numberCell, naCell, 'col')).toBe(
|
||||
'number',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -637,6 +637,21 @@ const generateData = (
|
||||
return data;
|
||||
};
|
||||
|
||||
export const compareTableColumnValues = (
|
||||
a: RowData,
|
||||
b: RowData,
|
||||
dataIndex: string,
|
||||
): number => {
|
||||
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
|
||||
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
|
||||
|
||||
if (!isNaN(valueA) && !isNaN(valueB)) {
|
||||
return valueA - valueB;
|
||||
}
|
||||
|
||||
return String(a[dataIndex] ?? '').localeCompare(String(b[dataIndex] ?? ''));
|
||||
};
|
||||
|
||||
const generateTableColumns = (
|
||||
dynamicColumns: DynamicColumns,
|
||||
renderColumnCell?: QueryTableProps['renderColumnCell'],
|
||||
@@ -650,18 +665,8 @@ const generateTableColumns = (
|
||||
title: item.title,
|
||||
width: QUERY_TABLE_CONFIG.width,
|
||||
render: renderColumnCell && renderColumnCell[dataIndex],
|
||||
sorter: (a: RowData, b: RowData): number => {
|
||||
const valueA = Number(a[`${dataIndex}_without_unit`] ?? a[dataIndex]);
|
||||
const valueB = Number(b[`${dataIndex}_without_unit`] ?? b[dataIndex]);
|
||||
|
||||
if (!isNaN(valueA) && !isNaN(valueB)) {
|
||||
return valueA - valueB;
|
||||
}
|
||||
|
||||
return ((a[dataIndex] as string) || '').localeCompare(
|
||||
(b[dataIndex] as string) || '',
|
||||
);
|
||||
},
|
||||
sorter: (a: RowData, b: RowData): number =>
|
||||
compareTableColumnValues(a, b, dataIndex),
|
||||
};
|
||||
|
||||
return [...acc, column];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,7 +103,7 @@ func (migration *addTelemetryTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@ func placedWidgetIDs(data StorableDashboardData) map[string]bool {
|
||||
for _, e := range layout {
|
||||
if m, ok := e.(map[string]any); ok {
|
||||
if i, ok := m["i"].(string); ok && i != "" {
|
||||
// A zero-width placement doesn't render in the v1 UI; treat it as
|
||||
// unplaced so the widget is dropped rather than migrated invisibly.
|
||||
if w, ok := m["w"].(float64); ok && w <= 0 {
|
||||
continue
|
||||
}
|
||||
ids[i] = true
|
||||
}
|
||||
}
|
||||
@@ -320,6 +325,9 @@ func compactGridItemsVertically(items []dashboard.GridItem) {
|
||||
if l.X < 0 {
|
||||
l.X = 0
|
||||
}
|
||||
if l.X+l.Width > gridColumnCount { // still overflows (wider than the grid) → clamp width so x+width = cols
|
||||
l.Width = gridColumnCount - l.X
|
||||
}
|
||||
if l.Y < 0 {
|
||||
l.Y = 0
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dashboardtypes
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -108,26 +109,12 @@ func normalizeFunctionArgs(query map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
// malformedOrderByValueKeys are v4 order-by columnNames meaning "order by the aggregation value"
|
||||
// that the v5 aggregation validator rejects (validateOrderByForAggregation). All resolve
|
||||
// to the same aggregation key. Add more as they surface. The frontend passes these
|
||||
// through (the query-service resolves them), but the v2 dashboard validator only accepts
|
||||
// a real aggregation key.
|
||||
var malformedOrderByValueKeys = map[string]bool{
|
||||
"#SIGNOZ_VALUE": true,
|
||||
"A": true,
|
||||
"A.count()": true,
|
||||
"__result": true,
|
||||
"value": true,
|
||||
"A.p99(duration_nano)": true,
|
||||
"aws_Kafka_MessagesInPerSec_max": true,
|
||||
"byte_in_count": true,
|
||||
"(http_server_request_duration_ms.bucket)": true,
|
||||
}
|
||||
|
||||
// normalizeOrderByKeys rewrites any orderBy columnName in orderByValueKeys to the
|
||||
// v5-valid aggregation key. Left untouched if the key can't resolve (no aggregation to
|
||||
// name).
|
||||
// normalizeOrderByKeys rewrites any orderBy columnName the v5 aggregation validator
|
||||
// would reject (validateOrderByForAggregation) to the canonical aggregation value key.
|
||||
// v1 tolerated free-form "order by the value" aliases (#SIGNOZ_VALUE, the query name,
|
||||
// the raw metric/expression) that the query-service resolved at query time; v2 accepts
|
||||
// only a real order key. Anything already valid (a group-by key, an aggregation
|
||||
// expression/alias/index) is left alone. No-op if no aggregation key can be named.
|
||||
func normalizeOrderByKeys(query map[string]any) {
|
||||
orders, ok := query["orderBy"].([]any)
|
||||
if !ok {
|
||||
@@ -137,17 +124,88 @@ func normalizeOrderByKeys(query map[string]any) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
valid := validAggregationOrderKeys(query)
|
||||
for _, o := range orders {
|
||||
order, ok := o.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[cn] {
|
||||
if cn, _ := order["columnName"].(string); cn != "" && !valid[cn] {
|
||||
order["columnName"] = key
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validAggregationOrderKeys is a line-for-line mirror of validateOrderByForAggregation
|
||||
// (querybuildertypesv5/validation.go) over the untyped query map instead of a typed
|
||||
// QueryBuilderQuery. Keep the two in lockstep — every insertion here must match one
|
||||
// there — so a side-by-side read makes any drift obvious. The only adaptations: fields
|
||||
// are read out of maps, the type switch on the aggregation becomes a switch on the
|
||||
// query signal (metrics vs logs/traces), and a not-yet-upgraded group-by still carries
|
||||
// the v4 "key" instead of the v5 "name".
|
||||
func validAggregationOrderKeys(query map[string]any) map[string]bool {
|
||||
validOrderKeys := make(map[string]bool)
|
||||
|
||||
for _, gb := range asObjects(query["groupBy"]) {
|
||||
name, _ := gb["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = gb["key"].(string)
|
||||
}
|
||||
validOrderKeys[name] = true
|
||||
}
|
||||
|
||||
signal := signalFromDataSource(query["dataSource"])
|
||||
for i, agg := range asObjects(query["aggregations"]) {
|
||||
validOrderKeys[fmt.Sprintf("%d", i)] = true
|
||||
|
||||
switch signal {
|
||||
// TraceAggregation / LogAggregation (identical bodies in the validator).
|
||||
case telemetrytypes.SignalTraces, telemetrytypes.SignalLogs:
|
||||
if alias, _ := agg["alias"].(string); alias != "" {
|
||||
validOrderKeys[alias] = true
|
||||
}
|
||||
expression, _ := agg["expression"].(string)
|
||||
validOrderKeys[expression] = true
|
||||
|
||||
// MetricAggregation.
|
||||
case telemetrytypes.SignalMetrics:
|
||||
// Also allow the generic __result pattern
|
||||
validOrderKeys["__result"] = true
|
||||
|
||||
metricName, _ := agg["metricName"].(string)
|
||||
spaceRaw, _ := agg["spaceAggregation"].(string)
|
||||
timeRaw, _ := agg["timeAggregation"].(string)
|
||||
spaceAggregation := metrictypes.SpaceAggregation{String: valuer.NewString(spaceRaw)}
|
||||
timeAggregation := metrictypes.TimeAggregation{String: valuer.NewString(timeRaw)}
|
||||
|
||||
validOrderKeys[fmt.Sprintf("%s(%s)", spaceAggregation.StringValue(), metricName)] = true
|
||||
if timeAggregation != metrictypes.TimeAggregationUnspecified {
|
||||
validOrderKeys[fmt.Sprintf("%s(%s)", timeAggregation.StringValue(), metricName)] = true
|
||||
}
|
||||
if timeAggregation != metrictypes.TimeAggregationUnspecified && spaceAggregation != metrictypes.SpaceAggregationUnspecified {
|
||||
validOrderKeys[fmt.Sprintf("%s(%s(%s))", spaceAggregation.StringValue(), timeAggregation.StringValue(), metricName)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validOrderKeys
|
||||
}
|
||||
|
||||
// asObjects returns the map elements of a []any, skipping non-object entries.
|
||||
func asObjects(raw any) []map[string]any {
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, it := range items {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// aggregationOrderKey names the first aggregation the way validateOrderByForAggregation
|
||||
// expects: "space(metricName)" for metrics, the expression for logs/traces.
|
||||
func aggregationOrderKey(query map[string]any) (string, bool) {
|
||||
|
||||
@@ -1738,6 +1738,47 @@ func TestConvertV1WidgetQueryRewritesValueOrderKeyAfterDefaultAggregation(t *tes
|
||||
assert.Equal(t, "count()", spec.Order[0].Key.Name, "#SIGNOZ_VALUE resolves to the injected default aggregation")
|
||||
}
|
||||
|
||||
// A metric query ordering by a value alias the v5 validator rejects (here the raw
|
||||
// metric name, never enumerated anywhere) is rewritten to the canonical aggregation
|
||||
// key, while a genuine group-by order key is left alone. Guards the allowlist
|
||||
// approach: validity is derived from the query, not a hardcoded set of bad keys.
|
||||
func TestConvertV1WidgetQueryRewritesUnknownMetricValueOrderKey(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "m-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "metrics",
|
||||
"aggregations": []any{map[string]any{"metricName": "http_requests_total", "spaceAggregation": "sum"}},
|
||||
"groupBy": []any{map[string]any{"key": "service.name", "dataType": "string", "type": "resource"}},
|
||||
"orderBy": []any{
|
||||
map[string]any{"columnName": "http_requests_total", "order": "desc"},
|
||||
map[string]any{"columnName": "service.name", "order": "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := (&v1Decoder{}).convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
|
||||
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "metrics query should dispatch to MetricAggregation, got %T", wrapper.Spec)
|
||||
|
||||
require.Len(t, spec.Order, 2)
|
||||
assert.Equal(t, "sum(http_requests_total)", spec.Order[0].Key.Name, "unknown value alias rewritten to the aggregation key")
|
||||
assert.Equal(t, "service.name", spec.Order[1].Key.Name, "valid group-by order key left alone")
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryInjectsCountForNoopOnAggregationPanel(t *testing.T) {
|
||||
// A logs query with the list-style "noop" operator placed on an aggregation
|
||||
// panel (graph). createAggregationsShapeSafe drops noop, leaving no aggregation;
|
||||
@@ -2084,6 +2125,26 @@ func TestConvertV1LayoutsClampsXBounds(t *testing.T) {
|
||||
assert.Equal(t, 6, grid.Items[1].X) // x+w=16>12 shifted left to 12-6
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsClampsOverwideWidth(t *testing.T) {
|
||||
// A widget wider than the 12-col grid (e.g. carried over from a 24-col v1 grid)
|
||||
// can't be shifted to fit, so its width is clamped so x+width = grid width —
|
||||
// otherwise it overflows and v2 validation rejects it.
|
||||
data := StorableDashboardData{
|
||||
"widgets": []any{map[string]any{"id": "wide", "panelTypes": "graph", "query": singleLogsBuilderQuery()}},
|
||||
"layout": []any{map[string]any{"i": "wide", "x": float64(0), "y": float64(0), "w": float64(24), "h": float64(6)}},
|
||||
}
|
||||
|
||||
d := &v1Decoder{}
|
||||
layouts := d.convertV1Layouts(data, d.convertV1Panels(data["widgets"]))
|
||||
require.NoError(t, d.errIfHasMalformedFields())
|
||||
require.Len(t, layouts, 1)
|
||||
grid, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, grid.Items, 1)
|
||||
assert.Equal(t, 0, grid.Items[0].X)
|
||||
assert.Equal(t, 12, grid.Items[0].Width) // w=24 clamped to 12 so x+width = 12
|
||||
}
|
||||
|
||||
// TestConvertV1LayoutsToleratesNonObjectPanelMap covers templates that store
|
||||
// panelMap as {rowID: []widgetID} instead of the canonical {rowID: {widgets,
|
||||
// collapsed}}. The frontend reads such an entry as "not collapsed" (it accesses
|
||||
@@ -2152,6 +2213,33 @@ func TestConvertV1LayoutsDropsEntryForUnrenderableWidget(t *testing.T) {
|
||||
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
|
||||
}
|
||||
|
||||
func TestRetainPlacedWidgetsDropsZeroWidth(t *testing.T) {
|
||||
// z-1 is placed with zero width, which the v1 UI doesn't render. It must be
|
||||
// dropped entirely: no panel, and no dangling layout entry.
|
||||
data := StorableDashboardData{
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "p-1", "panelTypes": "graph", "query": singleLogsBuilderQuery()},
|
||||
map[string]any{"id": "z-1", "panelTypes": "graph", "query": singleLogsBuilderQuery()},
|
||||
},
|
||||
"layout": []any{
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(0), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "z-1", "x": float64(6), "y": float64(0), "w": float64(0), "h": float64(6)},
|
||||
},
|
||||
}
|
||||
|
||||
d := &v1Decoder{}
|
||||
panels := d.convertV1Panels(retainPlacedWidgets(data))
|
||||
require.Contains(t, panels, "p-1")
|
||||
require.NotContains(t, panels, "z-1", "a zero-width widget is not retained → no panel")
|
||||
|
||||
layouts := d.convertV1Layouts(data, panels)
|
||||
require.Len(t, layouts, 1)
|
||||
spec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.Items, 1, "the zero-width widget's layout entry is dropped too")
|
||||
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsDropsCollapsedChildWithNoPanel(t *testing.T) {
|
||||
// A collapsed section lists a child ("ghost") that has no widget at all — a
|
||||
// deleted widget still referenced in panelMap. It produces no panel and no
|
||||
|
||||
@@ -140,49 +140,6 @@ type seriesLookup struct {
|
||||
// maps a variable to its series keys, letting evaluation iterate a single
|
||||
// variable's series directly.
|
||||
variableToSeriesKeys map[string][]string
|
||||
// seriesKey -> label set encoded as id pairs, used as the "subset" side of isSubset
|
||||
// during dedup and evaluation so label comparison is an integer compare.
|
||||
encodedLabels map[string][]encodedLabel
|
||||
}
|
||||
|
||||
// encodedLabel is a label's key name and value replaced with stable integer ids by an
|
||||
// encodedLabelsBuilder, so comparing two encodedLabels is an integer compare rather than
|
||||
// a per-byte string compare (runtime.efaceeq/strequal/memeqbody).
|
||||
type encodedLabel struct {
|
||||
keyID uint32
|
||||
valueID uint32
|
||||
}
|
||||
|
||||
// encodedLabelsBuilder stores a stable uint32 id for each distinct label key name and label
|
||||
// value (dictionary encoding). Populated single-threaded in buildSeriesLookup and
|
||||
// read-only thereafter, so the parallel evaluation phase never writes to it.
|
||||
type encodedLabelsBuilder struct {
|
||||
encodedKeyIDs map[string]uint32
|
||||
encodedValueIDs map[any]uint32
|
||||
}
|
||||
|
||||
func newEncodedLabelsBuilder() *encodedLabelsBuilder {
|
||||
return &encodedLabelsBuilder{encodedKeyIDs: map[string]uint32{}, encodedValueIDs: map[any]uint32{}}
|
||||
}
|
||||
|
||||
func (b *encodedLabelsBuilder) encode(labels []*Label) []encodedLabel {
|
||||
out := make([]encodedLabel, len(labels))
|
||||
for i, label := range labels {
|
||||
encodedKey, ok := b.encodedKeyIDs[label.Key.Name]
|
||||
if !ok {
|
||||
encodedKey = uint32(len(b.encodedKeyIDs)) // acts as a sequential counter.
|
||||
b.encodedKeyIDs[label.Key.Name] = encodedKey
|
||||
}
|
||||
|
||||
encodedValue, ok := b.encodedValueIDs[label.Value]
|
||||
if !ok {
|
||||
encodedValue = uint32(len(b.encodedValueIDs)) // acts as a sequential counter.
|
||||
b.encodedValueIDs[label.Value] = encodedValue
|
||||
}
|
||||
|
||||
out[i] = encodedLabel{keyID: encodedKey, valueID: encodedValue}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormulaEvaluator handles formula evaluation b/w time series from different aggregations
|
||||
@@ -342,7 +299,7 @@ func (fe *FormulaEvaluator) EvaluateFormula(timeSeriesData map[string]*TimeSerie
|
||||
// Work per label-set is cheap enough that spawning a goroutine per item
|
||||
// costs more in scheduler signaling than it saves in parallelism.
|
||||
const numWorkers = 4
|
||||
workCh := make(chan labelSetCandidate, len(uniqueLabelSets))
|
||||
workCh := make(chan []*Label, len(uniqueLabelSets))
|
||||
resultChan := make(chan *TimeSeries, len(uniqueLabelSets))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -391,12 +348,8 @@ func (fe *FormulaEvaluator) buildSeriesLookup(timeSeriesData map[string]*TimeSer
|
||||
seriesMetadata: make(map[string]*TimeSeries),
|
||||
|
||||
variableToSeriesKeys: make(map[string][]string),
|
||||
|
||||
encodedLabels: make(map[string][]encodedLabel),
|
||||
}
|
||||
|
||||
encodedLabelsBuilder := newEncodedLabelsBuilder()
|
||||
|
||||
for variable, aggRef := range fe.aggRefs {
|
||||
// We are only interested in the time series data for the queries that are
|
||||
// involved in the formula expression.
|
||||
@@ -446,7 +399,6 @@ func (fe *FormulaEvaluator) buildSeriesLookup(timeSeriesData map[string]*TimeSer
|
||||
if _, exists := lookup.data[seriesKey]; !exists {
|
||||
lookup.data[seriesKey] = make(map[int64]float64, len(series.Values))
|
||||
lookup.seriesMetadata[seriesKey] = series
|
||||
lookup.encodedLabels[seriesKey] = encodedLabelsBuilder.encode(series.Labels)
|
||||
lookup.variableToSeriesKeys[variable] = append(lookup.variableToSeriesKeys[variable], seriesKey)
|
||||
}
|
||||
|
||||
@@ -509,74 +461,58 @@ func (fe *FormulaEvaluator) buildSeriesKey(variable string, seriesIndex int, lab
|
||||
// The result of any expression that uses the series with `{"service": "frontend", "operation": "GET /api"}`
|
||||
// and `{"service": "frontend"}` would be the series with `{"service": "frontend", "operation": "GET /api"}`
|
||||
// So, we create a set of labels sets that can be termed as candidates for the final result.
|
||||
func (fe *FormulaEvaluator) findUniqueLabelSets(lookup *seriesLookup) []labelSetCandidate {
|
||||
// original labels (for the result series) paired with their encoded form (for the
|
||||
// subset comparison).
|
||||
type labelSet struct {
|
||||
labels []*Label
|
||||
encoded []encodedLabel
|
||||
}
|
||||
allLabelSets := make([]labelSet, 0, len(lookup.seriesMetadata))
|
||||
for key, series := range lookup.seriesMetadata {
|
||||
allLabelSets = append(allLabelSets, labelSet{labels: series.Labels, encoded: lookup.encodedLabels[key]})
|
||||
func (fe *FormulaEvaluator) findUniqueLabelSets(lookup *seriesLookup) [][]*Label {
|
||||
var allLabelSets [][]*Label
|
||||
|
||||
// Collect all label sets from series metadata
|
||||
for _, series := range lookup.seriesMetadata {
|
||||
allLabelSets = append(allLabelSets, series.Labels)
|
||||
}
|
||||
|
||||
// sort the label sets by the number of labels in descending order.
|
||||
slices.SortFunc(allLabelSets, func(i, j labelSet) int {
|
||||
if len(i.labels) > len(j.labels) {
|
||||
// sort the label sets by the number of labels in descending order
|
||||
slices.SortFunc(allLabelSets, func(i, j []*Label) int {
|
||||
if len(i) > len(j) {
|
||||
return -1
|
||||
}
|
||||
if len(i.labels) < len(j.labels) {
|
||||
if len(i) < len(j) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Find unique label sets using integer-id label comparison.
|
||||
var uniqueSets []labelSetCandidate
|
||||
// Find unique label sets using proper label comparison
|
||||
var uniqueSets [][]*Label
|
||||
var uniqueMaps []map[string]any
|
||||
for _, labelSet := range allLabelSets {
|
||||
isUnique := true
|
||||
for _, uniqueSet := range uniqueSets {
|
||||
if isSubset(uniqueSet.encodedKeyToEncodedValueMap, labelSet.encoded) {
|
||||
for _, uniqueMap := range uniqueMaps {
|
||||
if isSubset(uniqueMap, labelSet) {
|
||||
isUnique = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isUnique {
|
||||
uniqueSets = append(uniqueSets, labelSetCandidate{
|
||||
labels: labelSet.labels,
|
||||
encodedKeyToEncodedValueMap: encodedLabelsToMap(labelSet.encoded),
|
||||
})
|
||||
uniqueSets = append(uniqueSets, labelSet)
|
||||
uniqueMaps = append(uniqueMaps, labelsToMap(labelSet))
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueSets
|
||||
}
|
||||
|
||||
// labelSetCandidate is a maximal label set kept by findUniqueLabelSets: the original
|
||||
// labels (preserved for the result series) plus a keyID->valueID map used as the
|
||||
// "superset" when matching series during evaluation.
|
||||
type labelSetCandidate struct {
|
||||
labels []*Label
|
||||
encodedKeyToEncodedValueMap map[uint32]uint32
|
||||
}
|
||||
|
||||
func encodedLabelsToMap(labels []encodedLabel) map[uint32]uint32 {
|
||||
m := make(map[uint32]uint32, len(labels))
|
||||
func labelsToMap(labels []*Label) map[string]any {
|
||||
m := make(map[string]any, len(labels))
|
||||
for _, label := range labels {
|
||||
m[label.keyID] = label.valueID
|
||||
m[label.Key.Name] = label.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isSubset reports whether every label in subset is present with the same value in
|
||||
// supersetMap (i.e. subset ⊆ superset).
|
||||
func isSubset(supersetMap map[uint32]uint32, subset []encodedLabel) bool {
|
||||
func isSubset(supersetMap map[string]any, subset []*Label) bool {
|
||||
for _, label := range subset {
|
||||
// each key and value of string/any type in the overall label set in the whole result
|
||||
// has a corresponding unique uint32 assigned to it by buildSeriesLookup, which helps us
|
||||
// do a simple integer comparison in place of a much more expensive str/any comparison.
|
||||
if valID, ok := supersetMap[label.keyID]; !ok || valID != label.valueID {
|
||||
if val, ok := supersetMap[label.Key.Name]; !ok || val != label.Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -584,7 +520,7 @@ func isSubset(supersetMap map[uint32]uint32, subset []encodedLabel) bool {
|
||||
}
|
||||
|
||||
// evaluateForLabelSet performs formula evaluation for a specific label set.
|
||||
func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels labelSetCandidate, lookup *seriesLookup) *TimeSeries {
|
||||
func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *seriesLookup) *TimeSeries {
|
||||
// Find matching series for each variable
|
||||
variableData := make(map[string]map[int64]float64)
|
||||
// not every series would have a value for every timestamp
|
||||
@@ -592,14 +528,14 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels labelSetCandidate,
|
||||
// for the variable
|
||||
var allTimestamps = make(map[int64]struct{})
|
||||
|
||||
// target.encodedKeyToEncodedValueMap is the superset map, precomputed once in
|
||||
// findUniqueLabelSets and reused across every series comparison below.
|
||||
targetMap := targetLabels.encodedKeyToEncodedValueMap
|
||||
// targetLabels is fixed for this call, so build its lookup once and reuse it
|
||||
// across every series comparison below.
|
||||
targetMap := labelsToMap(targetLabels)
|
||||
|
||||
for variable := range fe.aggRefs {
|
||||
// only this variable's series.
|
||||
for _, seriesKey := range lookup.variableToSeriesKeys[variable] {
|
||||
if isSubset(targetMap, lookup.encodedLabels[seriesKey]) {
|
||||
if isSubset(targetMap, lookup.seriesMetadata[seriesKey].Labels) {
|
||||
if timestampData, exists := lookup.data[seriesKey]; exists {
|
||||
variableData[variable] = timestampData
|
||||
// Collect all timestamps
|
||||
@@ -687,8 +623,8 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels labelSetCandidate,
|
||||
}
|
||||
|
||||
// Preserve original label structure and metadata
|
||||
resultLabels := make([]*Label, len(targetLabels.labels))
|
||||
copy(resultLabels, targetLabels.labels)
|
||||
resultLabels := make([]*Label, len(targetLabels))
|
||||
copy(resultLabels, targetLabels)
|
||||
|
||||
return &TimeSeries{
|
||||
Labels: resultLabels,
|
||||
|
||||
@@ -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