Compare commits

..

2 Commits

Author SHA1 Message Date
Naman Verma
76cf4c98b4 Merge branch 'main' into nv/str-equal-issubset 2026-07-27 14:17:07 +05:30
Naman Verma
3028f19298 perf: encode label key and value as string to remove str comparison from subset check 2026-07-06 19:55:18 +05:30
42 changed files with 379 additions and 1112 deletions

View File

@@ -9934,9 +9934,14 @@ paths:
get:
deprecated: false
description: This endpoint lists the services metadata for the specified cloud
provider, without any account context.
provider
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -9986,10 +9991,14 @@ paths:
/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}:
get:
deprecated: false
description: This endpoint gets a service definition for the specified cloud
provider, without any account context.
description: This endpoint gets a service for the specified cloud provider
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true

View File

@@ -36,12 +36,14 @@ import type {
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccountServicesMetadata200,
ListAccountServicesMetadataPathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -1160,24 +1162,30 @@ export const invalidateGetConnectionCredentials = async (
};
/**
* This endpoint lists the services metadata for the specified cloud provider, without any account context.
* This endpoint lists the services metadata for the specified cloud provider
* @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) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
};
export const getListServicesMetadataQueryOptions = <
@@ -1185,6 +1193,7 @@ export const getListServicesMetadataQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1196,11 +1205,12 @@ export const getListServicesMetadataQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
return {
queryKey,
@@ -1228,6 +1238,7 @@ export function useListServicesMetadata<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1238,6 +1249,7 @@ export function useListServicesMetadata<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
@@ -1254,10 +1266,11 @@ export function useListServicesMetadata<
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
options,
);
@@ -1265,26 +1278,29 @@ export const invalidateListServicesMetadata = async (
};
/**
* This endpoint gets a service definition for the specified cloud provider, without any account context.
* This endpoint gets a service for the specified cloud provider
* @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) => {
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
@@ -1293,6 +1309,7 @@ export const getGetServiceQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1304,11 +1321,12 @@ export const getGetServiceQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, signal);
}) => getService({ cloudProvider, serviceId }, params, signal);
return {
queryKey,
@@ -1334,6 +1352,7 @@ export function useGetService<
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1344,6 +1363,7 @@ export function useGetService<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
@@ -1360,10 +1380,11 @@ 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 }) },
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
options,
);

View File

@@ -10204,6 +10204,14 @@ export type GetConnectionCredentials200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -10216,6 +10224,14 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**

View File

@@ -380,88 +380,4 @@ 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);
});
});
});

View File

@@ -273,19 +273,6 @@ 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
*/
@@ -298,22 +285,14 @@ function convertRawData(
queryName: rawData.queryName,
legend: legendMap[rawData.queryName] || rawData.queryName,
series: null,
list: rawData.rows?.map((row) => {
const data = {
list: rawData.rows?.map((row) => ({
timestamp: row.timestamp,
data: {
// Map raw data to ILog structure - spread row.data first to include all properties
...row.data,
date: row.timestamp,
} as any;
if ('body' in row.data) {
data.body = extractOnlyMessageBody(row.data.body);
}
return {
timestamp: row.timestamp,
data,
};
}),
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -1,8 +1,5 @@
// ** Helpers
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@@ -392,19 +389,11 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
// 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
) {
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

View File

@@ -167,56 +167,6 @@ 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, '');

View File

@@ -17,28 +17,6 @@ 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,

View File

@@ -16,22 +16,17 @@ 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 {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRules200,
} from 'api/generated/services/sigNoz.schemas';
import type { 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';
@@ -105,8 +100,6 @@ 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':
@@ -171,18 +164,6 @@ 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;
@@ -735,11 +716,9 @@ export default function ChatInput({
data: dashboardsResponse,
isLoading: isDashboardsLoading,
isError: isDashboardsError,
} = useListDashboardsForUserV2(DASHBOARD_LIST_PARAMS, {
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
},
} = useGetAllDashboard({
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
});
const {
@@ -786,12 +765,12 @@ export default function ChatInput({
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListDashboardsForUserV2200>(
getListDashboardsForUserV2QueryKey(DASHBOARD_LIST_PARAMS),
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
);
const dash = cached?.data?.dashboards?.find((d) => d.id === ctx.resourceId);
if (dash) {
return dashboardTitle(dash);
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
@@ -821,9 +800,9 @@ export default function ChatInput({
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
dashboardsResponse?.data?.dashboards?.map((dashboard) => ({
dashboardsResponse?.data?.map((dashboard) => ({
id: dashboard.id,
value: dashboardTitle(dashboard),
value: dashboard.data.title ?? 'Untitled',
})) ?? [],
Alerts:
alertsResponse?.data

View File

@@ -2,13 +2,12 @@ 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('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
jest.mock('hooks/dashboard/useGetAllDashboard', () => ({
useGetAllDashboard: (): unknown => ({
data: [],
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('api/generated/services/rules', () => ({

View File

@@ -2,7 +2,6 @@ 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';
/**
@@ -31,23 +30,22 @@ export function getAutoContexts(
// ── Dashboards ────────────────────────────────────────────────────────────
// 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 }>(
// Widget edit (panel_edit) — `/dashboard/:dashboardId/:widgetId`.
const widgetMatch = matchPath<{ dashboardId: string; widgetId: string }>(
pathname,
{ path: ROUTES.DASHBOARD_PANEL_EDITOR, exact: true },
{ path: ROUTES.DASHBOARD_WIDGET, exact: true },
);
if (panelEditorMatch) {
const { dashboardId, panelId } = panelEditorMatch.params;
const isNewPanel = panelId === NEW_PANEL_ID;
if (widgetMatch) {
return [
{
source: 'auto',
type: 'dashboard',
resourceId: dashboardId,
metadata: isNewPanel
? { page: 'panel_create', ...sharedMetadata }
: { page: 'panel_edit', widgetId: panelId, ...sharedMetadata },
resourceId: widgetMatch.params.dashboardId,
metadata: {
page: 'panel_edit',
widgetId: widgetMatch.params.widgetId,
...sharedMetadata,
},
},
];
}

View File

@@ -9,8 +9,6 @@ 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,

View File

@@ -147,6 +147,7 @@ function ServiceDetails({
cloudProvider: type,
serviceId: serviceId || '',
},
undefined,
{
query: {
enabled: !!serviceId && !cloudAccountId,

View File

@@ -39,12 +39,9 @@ function ServicesList({
const {
data: providerServicesMetadata,
isLoading: isProviderServicesLoading,
} = useListServicesMetadata(
{ cloudProvider: type },
{
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
},
);
} = useListServicesMetadata({ cloudProvider: type }, undefined, {
query: { enabled: !isAccountsLoading && !hasConnectedAccounts },
});
const servicesMetadata = hasConnectedAccounts
? accountServicesMetadata

View File

@@ -33,7 +33,6 @@ function AllAttributes({
metricName,
metricType,
isMonotonic,
temporality,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -72,7 +71,6 @@ function AllAttributes({
groupBy,
limit,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -91,7 +89,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
@@ -103,7 +101,6 @@ function AllAttributes({
undefined,
undefined,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -123,7 +120,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
[metricName, metricType, isMonotonic, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -86,7 +86,6 @@ function MetricDetails({
undefined,
undefined,
metadata?.isMonotonic,
metadata?.temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -109,7 +108,6 @@ function MetricDetails({
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
metadata?.temporality,
]);
useEffect(() => {
@@ -198,7 +196,6 @@ function MetricDetails({
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
temporality={metadata?.temporality}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -147,44 +147,6 @@ 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,

View File

@@ -35,7 +35,6 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,21 +89,16 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Monotonicity carries meaning only for cumulative sums; delta sums are
// counters regardless of monotonicity.
const isNonMonotonicCumulativeSum =
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
switch (metricType) {
case MetrictypesTypeDTO.sum:
if (isNonMonotonicCumulativeSum) {
if (isNonMonotonicSum) {
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
@@ -136,7 +131,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
const attributeType = toAttributeType(metricType, isMonotonic);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -8,7 +8,6 @@ import {
} from '@testing-library/react';
import {
MetricsexplorertypesListMetricDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENTITY_VERSION_V5 } from 'constants/app';
@@ -71,7 +70,7 @@ function makeMetric(
type: MetrictypesTypeDTO.sum,
isMonotonic: true,
description: '',
temporality: MetrictypesTemporalityDTO.cumulative,
temporality: 'cumulative' as never,
unit: '',
...overrides,
};
@@ -394,13 +393,12 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic cumulative Sum metric is treated as Gauge', () => {
it('non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: MetrictypesTemporalityDTO.cumulative,
}),
]);
@@ -429,36 +427,6 @@ 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 P50P99 space options', () => {
returnMetrics([
makeMetric({

View File

@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
return toAttributeType(metric.type, metric.isMonotonic);
}
function createAutocompleteData(

View File

@@ -1,82 +0,0 @@
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();
});
});

View File

@@ -1,11 +1,8 @@
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';
@@ -690,41 +687,4 @@ 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);
},
);
});
});

View File

@@ -1,111 +0,0 @@
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',
});
});
});

View File

@@ -1,9 +1,12 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
PANEL_TYPES,
QUERY_BUILDER_OPERATORS_BY_TYPES,
} from 'constants/queryBuilder';
import ContextMenu, { ClickedData } from 'periscope/components/ContextMenu';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getBaseMeta, getOperatorsByDataType } from './drilldownUtils';
import { getBaseMeta } from './drilldownUtils';
import { SUPPORTED_OPERATORS } from './menuOptions';
import { BreakoutAttributeType } from './types';
@@ -46,9 +49,15 @@ export function getGroupContextMenuConfig({
}: Omit<ContextMenuConfigParams, 'configType'>): GroupContextMenuConfig {
const filterKey = clickedData?.column?.dataIndex;
const filterDataType = getBaseMeta(query, filterKey as string)?.dataType;
const filterDataType =
getBaseMeta(query, filterKey as string)?.dataType || 'string';
const filterOperators = getOperatorsByDataType(filterDataType).filter(
const operators =
QUERY_BUILDER_OPERATORS_BY_TYPES[
filterDataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
];
const filterOperators = operators.filter(
(operator) => SUPPORTED_OPERATORS[operator],
);

View File

@@ -3,7 +3,6 @@ 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';
@@ -55,44 +54,13 @@ 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 NUMERIC_DATA_TYPES.includes(dataType);
return dataType === DataTypes.Int64 || dataType === DataTypes.Float64;
};
/**
* `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;

View File

@@ -1,94 +0,0 @@
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',
);
});
});

View File

@@ -637,21 +637,6 @@ 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'],
@@ -665,8 +650,18 @@ const generateTableColumns = (
title: item.title,
width: QUERY_TABLE_CONFIG.width,
render: renderColumnCell && renderColumnCell[dataIndex],
sorter: (a: RowData, b: RowData): number =>
compareTableColumnValues(a, b, 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) || '',
);
},
};
return [...acc, column];

View File

@@ -174,13 +174,11 @@ const baseProps = {
function setup(
panel: DashboardtypesPanelDTO,
overrides?: Partial<React.ComponentProps<typeof PanelEditorContainer>>,
draftOverrides?: { isSpecDirty?: boolean; draft?: DashboardtypesPanelDTO },
draftOverrides?: { isSpecDirty?: boolean },
): void {
// The live draft can diverge from the seed `panel`; default to the seed.
const draftPanel = draftOverrides?.draft ?? panel;
mockUseDraft.mockReturnValue({
draft: draftPanel,
spec: draftPanel.spec,
draft: panel,
spec: panel.spec,
setSpec: mockSetSpec,
isSpecDirty: draftOverrides?.isSpecDirty ?? false,
});
@@ -273,23 +271,6 @@ 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' } } },

View File

@@ -2,7 +2,6 @@ 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';
@@ -90,33 +89,6 @@ 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

View File

@@ -159,12 +159,11 @@ function PanelEditorContainer({
return section?.controls;
}, [panelDefinition]);
// 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.
// 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.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
() => isSpecDirty || isQueryDirty || (isNew && draft.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, draft.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';

View File

@@ -136,8 +136,9 @@ 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, without any account context.",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.ListServicesMetadataParams),
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
@@ -176,8 +177,9 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
ID: "GetService",
Tags: []string{"cloudintegration"},
Summary: "Get service",
Description: "This endpoint gets a service definition for the specified cloud provider, without any account context.",
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.GetServiceParams),
RequestContentType: "",
Response: new(citypes.Service),
ResponseContentType: "application/json",

View File

@@ -251,7 +251,23 @@ func (handler *handler) ListServicesMetadata(rw http.ResponseWriter, r *http.Req
return
}
services, err := handler.module.ListServicesMetadata(ctx, valuer.MustNewUUID(claims.OrgID), provider, valuer.UUID{})
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)
if err != nil {
render.Error(rw, err)
return
@@ -320,7 +336,22 @@ func (handler *handler) GetService(rw http.ResponseWriter, r *http.Request) {
return
}
svc, err := handler.module.GetService(ctx, valuer.MustNewUUID(claims.OrgID), serviceID, provider, valuer.UUID{})
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)
if err != nil {
render.Error(rw, err)
return

View File

@@ -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, 0, tupleID, now,
storeID, tuple.objectType, objectID, tuple.relation, user, "TUPLE_OPERATION_WRITE", tupleID, now,
)
if err != nil {
return err

View File

@@ -2359,10 +2359,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
if temporality != metrictypes.Unknown {
temporalities[metricName] = append(temporalities[metricName], temporality)
}
// 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 {
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
types[metricName] = metricType
@@ -2415,9 +2412,7 @@ 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")
}
// Non-monotonic cumulative sums are treated as gauges; delta sums are
// counters regardless of monotonicity.
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
temporalities[metricName] = temporality

View File

@@ -16,9 +16,7 @@ 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"
)
@@ -111,85 +109,3 @@ 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{}, &regexMatcher{})
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)
}
}

View File

@@ -44,6 +44,10 @@ 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 {
@@ -59,6 +63,10 @@ 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"`
}

View File

@@ -180,11 +180,6 @@ 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
}
}
@@ -325,9 +320,6 @@ 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
}

View File

@@ -3,7 +3,6 @@ package dashboardtypes
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
@@ -109,12 +108,26 @@ func normalizeFunctionArgs(query map[string]any) {
}
}
// 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.
// 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).
func normalizeOrderByKeys(query map[string]any) {
orders, ok := query["orderBy"].([]any)
if !ok {
@@ -124,88 +137,17 @@ 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); cn != "" && !valid[cn] {
if cn, _ := order["columnName"].(string); malformedOrderByValueKeys[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) {

View File

@@ -1738,47 +1738,6 @@ 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;
@@ -2125,26 +2084,6 @@ 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
@@ -2213,33 +2152,6 @@ 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

View File

@@ -140,6 +140,49 @@ 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
@@ -299,7 +342,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 []*Label, len(uniqueLabelSets))
workCh := make(chan labelSetCandidate, len(uniqueLabelSets))
resultChan := make(chan *TimeSeries, len(uniqueLabelSets))
var wg sync.WaitGroup
@@ -348,8 +391,12 @@ 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.
@@ -399,6 +446,7 @@ 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)
}
@@ -461,58 +509,74 @@ 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) [][]*Label {
var allLabelSets [][]*Label
// Collect all label sets from series metadata
for _, series := range lookup.seriesMetadata {
allLabelSets = append(allLabelSets, series.Labels)
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]})
}
// 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) {
// 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) {
return -1
}
if len(i) < len(j) {
if len(i.labels) < len(j.labels) {
return 1
}
return 0
})
// Find unique label sets using proper label comparison
var uniqueSets [][]*Label
var uniqueMaps []map[string]any
// Find unique label sets using integer-id label comparison.
var uniqueSets []labelSetCandidate
for _, labelSet := range allLabelSets {
isUnique := true
for _, uniqueMap := range uniqueMaps {
if isSubset(uniqueMap, labelSet) {
for _, uniqueSet := range uniqueSets {
if isSubset(uniqueSet.encodedKeyToEncodedValueMap, labelSet.encoded) {
isUnique = false
break
}
}
if isUnique {
uniqueSets = append(uniqueSets, labelSet)
uniqueMaps = append(uniqueMaps, labelsToMap(labelSet))
uniqueSets = append(uniqueSets, labelSetCandidate{
labels: labelSet.labels,
encodedKeyToEncodedValueMap: encodedLabelsToMap(labelSet.encoded),
})
}
}
return uniqueSets
}
func labelsToMap(labels []*Label) map[string]any {
m := make(map[string]any, len(labels))
// 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))
for _, label := range labels {
m[label.Key.Name] = label.Value
m[label.keyID] = label.valueID
}
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[string]any, subset []*Label) bool {
func isSubset(supersetMap map[uint32]uint32, subset []encodedLabel) bool {
for _, label := range subset {
if val, ok := supersetMap[label.Key.Name]; !ok || val != label.Value {
// 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 {
return false
}
}
@@ -520,7 +584,7 @@ func isSubset(supersetMap map[string]any, subset []*Label) bool {
}
// evaluateForLabelSet performs formula evaluation for a specific label set.
func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *seriesLookup) *TimeSeries {
func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels labelSetCandidate, 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
@@ -528,14 +592,14 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *s
// for the variable
var allTimestamps = make(map[int64]struct{})
// targetLabels is fixed for this call, so build its lookup once and reuse it
// across every series comparison below.
targetMap := labelsToMap(targetLabels)
// target.encodedKeyToEncodedValueMap is the superset map, precomputed once in
// findUniqueLabelSets and reused across every series comparison below.
targetMap := targetLabels.encodedKeyToEncodedValueMap
for variable := range fe.aggRefs {
// only this variable's series.
for _, seriesKey := range lookup.variableToSeriesKeys[variable] {
if isSubset(targetMap, lookup.seriesMetadata[seriesKey].Labels) {
if isSubset(targetMap, lookup.encodedLabels[seriesKey]) {
if timestampData, exists := lookup.data[seriesKey]; exists {
variableData[variable] = timestampData
// Collect all timestamps
@@ -623,8 +687,8 @@ func (fe *FormulaEvaluator) evaluateForLabelSet(targetLabels []*Label, lookup *s
}
// Preserve original label structure and metadata
resultLabels := make([]*Label, len(targetLabels))
copy(resultLabels, targetLabels)
resultLabels := make([]*Label, len(targetLabels.labels))
copy(resultLabels, targetLabels.labels)
return &TimeSeries{
Labels: resultLabels,

View File

@@ -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 the cloud provider's supported services"""
"""List available services without specifying a cloud_integration_id."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
@@ -54,6 +54,38 @@ 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"
@@ -122,6 +154,34 @@ 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
@@ -192,7 +252,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)
@@ -242,7 +302,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)
@@ -295,7 +355,7 @@ def test_list_services_account_removed(
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""List services for a deleted account returns 404."""
"""List services with a cloud_integration_id 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)
@@ -312,7 +372,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}/accounts/{account_id}/services"),
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,
)
@@ -326,7 +386,7 @@ def test_get_service_details_account_removed(
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""Get service details for a deleted account returns 404."""
"""Get service details with a cloud_integration_id 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)
@@ -343,7 +403,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)
@@ -408,7 +468,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)
@@ -473,7 +533,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)
@@ -492,7 +552,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}/accounts/{account_id}/services/{SERVICE_ID}"),
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,
)