Compare commits

..

3 Commits

Author SHA1 Message Date
Naman Verma
1c3b981179 chore: add api to retry migration for a dashboard 2026-08-04 00:09:40 +05:30
Naman Verma
cfaa7de165 fix: enforce the required tag on dashboard spec fields (#12381)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
* fix: enforace the required tag on dashboard spec fields

* test: add empty list and objects for required fields in integration tests
2026-08-03 18:22:43 +00:00
Naman Verma
5b3cc2400f chore: delta temporality metrics should always be considered as a sum metric (#12313)
* chore: delta temporality metrics should always be considered as a sum metric

* test: add integration tests

* test: parametrise the non-reduced test for rate and increase both

* test: add comment explaining last samples in test

* chore: remove unneeded comments

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-08-03 16:08:45 +00:00
65 changed files with 1232 additions and 2296 deletions

View File

@@ -4212,18 +4212,6 @@ components:
- missingOptionalMetrics
- missingRequiredAttributes
type: object
InframonitoringtypesClusterFilter:
properties:
expression:
type: string
filterByNodeReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesClusterRecord:
properties:
clusterCPU:
@@ -4460,16 +4448,6 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDaemonSetFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesDaemonSetRecord:
properties:
currentNodes:
@@ -4542,16 +4520,6 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesDeploymentFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesDeploymentRecord:
properties:
availablePods:
@@ -4693,16 +4661,6 @@ components:
- total
- endTimeBeforeRetention
type: object
InframonitoringtypesJobFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesJobRecord:
properties:
activePods:
@@ -4826,16 +4784,6 @@ components:
- message
- documentationLink
type: object
InframonitoringtypesNamespaceFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesNamespaceRecord:
properties:
counts:
@@ -4917,18 +4865,6 @@ components:
- ready
- notReady
type: object
InframonitoringtypesNodeFilter:
properties:
expression:
type: string
filterByNodeReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
@@ -5045,16 +4981,6 @@ components:
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesPodRecord:
properties:
meta:
@@ -5154,7 +5080,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesClusterFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5206,7 +5132,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesDaemonSetFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5232,7 +5158,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesDeploymentFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5284,7 +5210,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesJobFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5310,7 +5236,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesNamespaceFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5336,7 +5262,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesNodeFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5362,7 +5288,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesPodFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5388,7 +5314,7 @@ components:
format: int64
type: integer
filter:
$ref: '#/components/schemas/InframonitoringtypesStatefulSetFilter'
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
@@ -5439,16 +5365,6 @@ components:
- list
- grouped_list
type: string
InframonitoringtypesStatefulSetFilter:
properties:
expression:
type: string
filterByPodStatus:
items:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
nullable: true
type: array
type: object
InframonitoringtypesStatefulSetRecord:
properties:
currentPods:
@@ -15561,6 +15477,72 @@ paths:
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/migrate:
post:
deprecated: false
description: 'This endpoint retries the v1→v2 (Perses) migration on a dashboard
still stored in the v1 schema and returns the v2-shape result. It is idempotent:
a dashboard already in the v2 schema is returned unchanged.'
operationId: MigrateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}

View File

@@ -52,6 +52,8 @@ import type {
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
MigrateDashboardV2200,
MigrateDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
@@ -1804,6 +1806,85 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.
* @summary Migrate dashboard to v2
*/
export const migrateDashboardV2 = (
{ id }: MigrateDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<MigrateDashboardV2200>({
url: `/api/v2/dashboards/${id}/migrate`,
method: 'POST',
signal,
});
};
export const getMigrateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['migrateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof migrateDashboardV2>>,
{ pathParams: MigrateDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return migrateDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type MigrateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof migrateDashboardV2>>
>;
export type MigrateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Migrate dashboard to v2
*/
export const useMigrateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof migrateDashboardV2>>,
TError,
{ pathParams: MigrateDashboardV2PathParameters },
TContext
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -5635,44 +5635,6 @@ export interface InframonitoringtypesChecksDTO {
type: InframonitoringtypesCheckTypeDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesClusterFilterDTO {
/**
* @type string
*/
expression?: string;
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesClusterRecordDTOCounts = {
/**
* @type integer
@@ -6050,17 +6012,6 @@ export interface InframonitoringtypesContainersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesDaemonSetFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6146,17 +6097,6 @@ export interface InframonitoringtypesDaemonSetsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesDeploymentFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6319,17 +6259,6 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesJobFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6415,17 +6344,6 @@ export interface InframonitoringtypesJobsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesNamespaceFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesNamespaceRecordDTOCounts = {
/**
* @type integer
@@ -6502,18 +6420,11 @@ export interface InframonitoringtypesNamespacesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesNodeFilterDTO {
/**
* @type string
*/
expression?: string;
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6575,17 +6486,6 @@ export interface InframonitoringtypesNodesDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPodFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -6596,6 +6496,27 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6672,7 +6593,7 @@ export interface InframonitoringtypesPostableClustersDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesClusterFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6726,7 +6647,7 @@ export interface InframonitoringtypesPostableDaemonSetsDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesDaemonSetFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6753,7 +6674,7 @@ export interface InframonitoringtypesPostableDeploymentsDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesDeploymentFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6807,7 +6728,7 @@ export interface InframonitoringtypesPostableJobsDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesJobFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6834,7 +6755,7 @@ export interface InframonitoringtypesPostableNamespacesDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesNamespaceFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6861,7 +6782,7 @@ export interface InframonitoringtypesPostableNodesDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesNodeFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6888,7 +6809,7 @@ export interface InframonitoringtypesPostablePodsDTO {
* @format int64
*/
end: number;
filter?: InframonitoringtypesPodFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -6909,24 +6830,13 @@ export interface InframonitoringtypesPostablePodsDTO {
start: number;
}
export interface InframonitoringtypesStatefulSetFilterDTO {
/**
* @type string
*/
expression?: string;
/**
* @type array,null
*/
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
}
export interface InframonitoringtypesPostableStatefulSetsDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: InframonitoringtypesStatefulSetFilterDTO;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
@@ -11254,6 +11164,17 @@ export type UnlockDashboardV2PathParameters = {
export type LockDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2PathParameters = {
id: string;
};
export type MigrateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -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,17 @@ 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) {
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
if (
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative
) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

View File

@@ -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(

View File

@@ -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}
/>

View File

@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('treats a cumulative non-monotonic 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('treats a delta non-monotonic Sum as a Sum', () => {
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,6 +35,7 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,12 +89,16 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
switch (metricType) {
case MetrictypesTypeDTO.sum:
@@ -131,7 +135,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic);
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -393,12 +393,13 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'cumulative' as never,
}),
]);
@@ -427,6 +428,36 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('delta non-monotonic Sum metric is treated as Sum', () => {
returnMetrics([
makeMetric({
metricName: 'queue_depth_delta',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'delta' as never,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'queue_depth_delta' },
});
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);
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
}
function createAutocompleteData(

View File

@@ -85,6 +85,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/migrate", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.MigrateV2), handler.OpenAPIDef{
ID: "MigrateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Migrate dashboard to v2",
Description: "This endpoint retries the v1→v2 (Perses) migration on a dashboard still stored in the v1 schema and returns the v2-shape result. It is idempotent: a dashboard already in the v2 schema is returned unchanged.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},

View File

@@ -63,6 +63,9 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
ListForUserV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardForUserV2, error)
@@ -132,6 +135,8 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
MigrateV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
ListForUserV2(http.ResponseWriter, *http.Request)

View File

@@ -207,6 +207,38 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) MigrateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.MigrateV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
@@ -121,6 +122,51 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
// Already migrated: return as-is.
if storable.IsV2() {
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// v1→v2 needs v5-shaped queries; run v4→v5 in place first.
transition.NewDashboardMigrateV5(module.settings.Logger(), nil, nil).Migrate(ctx, storable.Data)
v2, err := storable.ConvertV1ToV2()
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
if err != nil {
return err
}
v2.Tags = resolvedTags
storableV2, err := v2.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storableV2)
})
if err != nil {
return nil, err
}
return v2, nil
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err

View File

@@ -84,38 +84,20 @@ func buildClusterRecords(
return records
}
// getTopClusterGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
// to intersect all).
func (m *module) getTopClusterGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableClusters,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
nodeConditionCounts map[string]nodeConditionCounts
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness inframonitoringtypes.NodeCondition
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status / node readiness, resolve the full-scope
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -124,37 +106,12 @@ func (m *module) getTopClusterGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if !filterByNodeReadiness.IsZero() {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
return err
})
}
if orderByKey == inframonitoringtypes.ClusterNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, nil, err
}
// Secondary filter: keep only status/readiness-matching groups. A missing
// metric yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
if !filterByNodeReadiness.IsZero() {
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ClusterNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
@@ -200,23 +157,10 @@ func (m *module) getTopClusterGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status/readiness
// keyset. A missing metric yields an empty keyset, correctly emptying the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
if !filterByNodeReadiness.IsZero() {
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
@@ -226,9 +170,5 @@ func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -90,34 +90,20 @@ func buildDaemonSetRecords(
return records
}
// getTopDaemonSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopDaemonSetGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableDaemonSets,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -126,26 +112,12 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.DaemonSetNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DaemonSetNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToDaemonSetsQueryNames[orderByKey]
@@ -191,19 +163,10 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
@@ -213,9 +176,5 @@ func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UU
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -82,34 +82,20 @@ func buildDeploymentRecords(
return records
}
// getTopDeploymentGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopDeploymentGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableDeployments,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -118,26 +104,12 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.DeploymentNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DeploymentNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToDeploymentsQueryNames[orderByKey]
@@ -183,19 +155,10 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
@@ -205,9 +168,5 @@ func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.U
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -63,33 +63,6 @@ func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByK
return compositeKeyFromList(parts)
}
// intersectMap returns the entries of m whose key is present in keep (a new
// map). keep's value type is irrelevant — only its keys are read — so a
// per-group counts map (already filtered by the SQL push-down) can be passed
// directly. Used to trim metadataMap to the status-matching groups.
func intersectMap[V any, K any](m map[string]V, keep map[string]K) map[string]V {
out := make(map[string]V, len(m))
for k, v := range m {
if _, ok := keep[k]; ok {
out[k] = v
}
}
return out
}
// intersectRankedGroups returns the ranked groups whose compositeKey is present
// in keep, preserving order. Keeps status-unmatched groups out of the ranked
// page slots.
func intersectRankedGroups[K any](groups []rankedGroup, keep map[string]K) []rankedGroup {
out := make([]rankedGroup, 0, len(groups))
for _, g := range groups {
if _, ok := keep[g.compositeKey]; ok {
out = append(out, g)
}
}
return out
}
// parseAndSortGroups extracts group label maps from a ScalarData response and
// sorts them by the ranking query's aggregation value.
func parseAndSortGroups(

View File

@@ -1,7 +1,6 @@
package implinframonitoring
import (
"reflect"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -384,81 +383,3 @@ func TestCompositeKeyFromLabels(t *testing.T) {
})
}
}
func TestIntersectMap(t *testing.T) {
tests := []struct {
name string
m map[string]int
keep map[string]podStatusCounts
expected map[string]int
}{
{
name: "keep subset",
m: map[string]int{"a": 1, "b": 2, "c": 3},
keep: map[string]podStatusCounts{"a": {}, "c": {}},
expected: map[string]int{"a": 1, "c": 3},
},
{
name: "empty keep drops everything",
m: map[string]int{"a": 1, "b": 2},
keep: map[string]podStatusCounts{},
expected: map[string]int{},
},
{
name: "keep key absent from m is ignored",
m: map[string]int{"a": 1},
keep: map[string]podStatusCounts{"a": {}, "z": {}},
expected: map[string]int{"a": 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectMap(tt.m, tt.keep)
if !reflect.DeepEqual(got, tt.expected) {
t.Errorf("intersectMap(%v, keys=%v) = %v, want %v", tt.m, tt.keep, got, tt.expected)
}
})
}
}
func TestIntersectRankedGroups(t *testing.T) {
groups := []rankedGroup{
{compositeKey: "a", value: 3},
{compositeKey: "b", value: 2},
{compositeKey: "c", value: 1},
}
tests := []struct {
name string
groups []rankedGroup
keep map[string]podStatusCounts
expected []string // compositeKeys in order
}{
{
name: "preserves order, drops non-matching",
groups: groups,
keep: map[string]podStatusCounts{"a": {}, "c": {}},
expected: []string{"a", "c"},
},
{
name: "empty keep drops all",
groups: groups,
keep: map[string]podStatusCounts{},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := intersectRankedGroups(tt.groups, tt.keep)
gotKeys := make([]string, 0, len(got))
for _, g := range got {
gotKeys = append(gotKeys, g.compositeKey)
}
if !reflect.DeepEqual(gotKeys, tt.expected) {
t.Errorf("intersectRankedGroups keys = %v, want %v", gotKeys, tt.expected)
}
})
}
}

View File

@@ -90,34 +90,20 @@ func buildJobRecords(
return records
}
// getTopJobGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopJobGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableJobs,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -126,26 +112,12 @@ func (m *module) getTopJobGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.JobNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.JobNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToJobsQueryNames[orderByKey]
@@ -191,19 +163,10 @@ func (m *module) getTopJobGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
@@ -213,9 +176,5 @@ func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, re
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -286,36 +286,11 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
var (
filterExpr string
podFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
restartCounts map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
podFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopPodGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, statusCounts, statusWarning, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && statusWarning != nil {
resp.Warning = statusWarning
resp.Records = []inframonitoringtypes.PodRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -323,8 +298,20 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
restartCounts map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -334,18 +321,14 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups)
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
// When filtering, statusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -495,37 +478,11 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return resp, nil
}
var (
filterExpr string
nodeFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness inframonitoringtypes.NodeCondition
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
nodeFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
// getTopNodeGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status / node readiness when filtering) concurrently, intersecting
// metadata/ranked groups against the keysets. It returns the keysets + warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCounts, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -533,8 +490,20 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCounts map[string]nodeConditionCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -542,24 +511,16 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering by readiness, nodeConditionCounts already holds the full-scope
// map (a superset of the page); otherwise compute it page-scoped here.
if filterByNodeReadiness.IsZero() {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, inframonitoringtypes.NodeCondition{})
return err
})
}
// When filtering by pod status, podStatusCounts already holds the full-scope
// map; otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
@@ -610,36 +571,11 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
var (
filterExpr string
namespaceFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
resourceCounts map[string]map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
namespaceFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopNamespaceGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.NamespaceRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -647,8 +583,20 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -658,18 +606,14 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -719,39 +663,11 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
return resp, nil
}
var (
filterExpr string
clusterFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness inframonitoringtypes.NodeCondition
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
resourceCounts map[string]map[string]int64
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
clusterFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
// getTopClusterGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status / node readiness when filtering) concurrently, intersecting
// metadata/ranked groups against the keysets. It returns the keysets + warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCountsMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.ClusterRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -759,8 +675,23 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
// with a custom groupBy, they aggregate across clusters in that group.
var (
queryResp *qbtypes.QueryRangeResponse
nodeConditionCountsMap map[string]nodeConditionCounts
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
resourceCounts map[string]map[string]int64
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -768,29 +699,21 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering by readiness, nodeConditionCountsMap already holds the
// full-scope map (a superset of the page); otherwise compute it page-scoped here.
if filterByNodeReadiness.IsZero() {
g.Go(func() error {
var err error
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, inframonitoringtypes.NodeCondition{})
return err
})
}
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
g.Go(func() error {
var err error
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
return err
})
// When filtering by pod status, podStatusCounts already holds the full-scope
// map; otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, nil)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
@@ -904,7 +827,7 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
// Bake the deployments base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &inframonitoringtypes.DeploymentFilter{}
req.Filter = &qbtypes.Filter{}
}
req.Filter.Expression = mergeFilterExpressions(deploymentsBaseFilterExpr, req.Filter.Expression)
@@ -919,35 +842,11 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
return resp, nil
}
var (
filterExpr string
deploymentFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
deploymentFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopDeploymentGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.DeploymentRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -955,8 +854,19 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -964,15 +874,11 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, deploymentFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
@@ -1013,7 +919,7 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &inframonitoringtypes.StatefulSetFilter{}
req.Filter = &qbtypes.Filter{}
}
req.Filter.Expression = mergeFilterExpressions(statefulSetsBaseFilterExpr, req.Filter.Expression)
@@ -1028,35 +934,11 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return resp, nil
}
var (
filterExpr string
statefulSetFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
statefulSetFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopStatefulSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.StatefulSetRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -1064,8 +946,21 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
// so default-groupBy gives per-statefulset status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -1073,15 +968,11 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, statefulSetFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
@@ -1122,7 +1013,7 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
// Bake the jobs base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &inframonitoringtypes.JobFilter{}
req.Filter = &qbtypes.Filter{}
}
req.Filter.Expression = mergeFilterExpressions(jobsBaseFilterExpr, req.Filter.Expression)
@@ -1137,35 +1028,11 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
var (
filterExpr string
jobFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
jobFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopJobGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.JobRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -1173,8 +1040,21 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
// gives per-job status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -1182,15 +1062,11 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, jobFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err
@@ -1231,7 +1107,7 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
if req.Filter == nil {
req.Filter = &inframonitoringtypes.DaemonSetFilter{}
req.Filter = &qbtypes.Filter{}
}
req.Filter.Expression = mergeFilterExpressions(daemonSetsBaseFilterExpr, req.Filter.Expression)
@@ -1246,35 +1122,11 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
var (
filterExpr string
daemonSetFilter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
queryResp *qbtypes.QueryRangeResponse
)
if req.Filter != nil {
filterExpr = req.Filter.Expression
daemonSetFilter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
// getTopDaemonSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
// status when filtering) concurrently, intersecting metadata/ranked groups
// against the status keyset. It returns the keyset + its warning.
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
pageGroups, metadataMap, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
if err != nil {
return nil, err
}
// Required metric missing while filtering: surface the warning + empty result.
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
resp.Warning = podStatusWarning
resp.Records = []inframonitoringtypes.DaemonSetRecord{}
resp.Total = 0
return resp, nil
}
resp.Total = len(metadataMap)
if len(pageGroups) == 0 {
@@ -1282,8 +1134,21 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
// so default-groupBy gives per-daemonset status counts automatically.
var (
queryResp *qbtypes.QueryRangeResponse
podStatusCounts map[string]podStatusCounts
podStatusWarning *qbtypes.QueryWarnData
)
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -1291,15 +1156,11 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
// When filtering, podStatusCounts already holds the full-scope map (a superset
// of the page); otherwise compute it page-scoped here.
if len(filterByPodStatus) == 0 {
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, daemonSetFilter, req.GroupBy, pageGroups, nil)
return err
})
}
g.Go(func() error {
var err error
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
return err
})
if err := g.Wait(); err != nil {
return nil, err

View File

@@ -65,34 +65,20 @@ func buildNamespaceRecords(
return records
}
// getTopNamespaceGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopNamespaceGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNamespaces,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -101,26 +87,12 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.NamespaceNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NamespaceNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToNamespacesQueryNames[orderByKey]
@@ -166,19 +138,10 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
@@ -188,9 +151,5 @@ func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UU
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -92,38 +92,20 @@ func buildNodeRecords(
return records
}
// getTopNodeGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
// to intersect all).
func (m *module) getTopNodeGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNodes,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
nodeConditionCounts map[string]nodeConditionCounts
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
filterByNodeReadiness inframonitoringtypes.NodeCondition
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status / node readiness, resolve the full-scope
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -132,37 +114,12 @@ func (m *module) getTopNodeGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if !filterByNodeReadiness.IsZero() {
g.Go(func() error {
var err error
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
return err
})
}
if orderByKey == inframonitoringtypes.NodeNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, nil, err
}
// Secondary filter: keep only status/readiness-matching groups. A missing
// metric yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
}
if !filterByNodeReadiness.IsZero() {
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NodeNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
@@ -208,23 +165,10 @@ func (m *module) getTopNodeGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status/readiness
// keyset. A missing metric yields an empty keyset, correctly emptying the result
// (the caller also surfaces the warning). Filters compose as AND.
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
if !filterByNodeReadiness.IsZero() {
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
@@ -234,11 +178,7 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
@@ -252,21 +192,6 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
//
// Groups absent from the result map have implicit zero counts (caller default).
// nodeReadinessFilterClause returns the outer WHERE clause (and its arg) that
// restricts latest_condition_per_node to a single readiness. condition_value is
// numeric (1=Ready, 0=NotReady), so we map the enum to its int. Empty readiness
// returns no clause.
func nodeReadinessFilterClause(filterByNodeReadiness inframonitoringtypes.NodeCondition) (string, []any) {
if filterByNodeReadiness.IsZero() {
return "", nil
}
v := inframonitoringtypes.NodeConditionNumNotReady
if filterByNodeReadiness == inframonitoringtypes.NodeConditionReady {
v = inframonitoringtypes.NodeConditionNumReady
}
return " WHERE condition_value = ? ", []any{v}
}
func (m *module) getPerGroupNodeConditionCounts(
ctx context.Context,
orgID valuer.UUID,
@@ -274,11 +199,8 @@ func (m *module) getPerGroupNodeConditionCounts(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByNodeReadiness inframonitoringtypes.NodeCondition,
) (map[string]nodeConditionCounts, error) {
// Empty pageGroups means "span all under user filter", allowed only in
// full-scope mode (filtering by readiness). Otherwise it's an empty page.
if len(groupBy) == 0 || (len(pageGroups) == 0 && filterByNodeReadiness.IsZero()) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]nodeConditionCounts{}, nil
}
@@ -366,12 +288,9 @@ func (m *module) getPerGroupNodeConditionCounts(
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
)
// Push-down: keep only nodes whose readiness matches the requested one.
readinessWhereClause, readinessWhereArgs := nodeReadinessFilterClause(filterByNodeReadiness)
countNodesPerConditionSQL := fmt.Sprintf(
"SELECT %s FROM latest_condition_per_node%s GROUP BY %s",
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
strings.Join(countNodesPerConditionSelectCols, ", "),
readinessWhereClause,
strings.Join(countNodesPerConditionGroupBy, ", "),
)
@@ -381,7 +300,7 @@ func (m *module) getPerGroupNodeConditionCounts(
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, readinessWhereArgs)
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {

View File

@@ -1,48 +0,0 @@
package implinframonitoring
import (
"reflect"
"testing"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
)
func TestNodeReadinessFilterClause(t *testing.T) {
tests := []struct {
name string
readiness inframonitoringtypes.NodeCondition
wantClause string
wantArgs []any
}{
{
name: "empty readiness yields no clause",
readiness: inframonitoringtypes.NodeCondition{},
wantClause: "",
wantArgs: nil,
},
{
name: "ready maps to 1",
readiness: inframonitoringtypes.NodeConditionReady,
wantClause: " WHERE condition_value = ? ",
wantArgs: []any{inframonitoringtypes.NodeConditionNumReady},
},
{
name: "not_ready maps to 0",
readiness: inframonitoringtypes.NodeConditionNotReady,
wantClause: " WHERE condition_value = ? ",
wantArgs: []any{inframonitoringtypes.NodeConditionNumNotReady},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotClause, gotArgs := nodeReadinessFilterClause(tt.readiness)
if gotClause != tt.wantClause {
t.Errorf("nodeReadinessFilterClause(%v) clause = %q, want %q", tt.readiness, gotClause, tt.wantClause)
}
if !reflect.DeepEqual(gotArgs, tt.wantArgs) {
t.Errorf("nodeReadinessFilterClause(%v) args = %v, want %v", tt.readiness, gotArgs, tt.wantArgs)
}
})
}
}

View File

@@ -146,34 +146,24 @@ func buildPodRecords(
return records
}
// getTopPodGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
// getTopPodGroupsAndMetadata fetches the group metadata and the ordering-metric
// ranking concurrently, then pages the ranked groups, backfilling from metadata
// when the page extends past the metric-ranked groups. Returns the page of
// groups and the metadata map (needed by the caller for Total and records).
func (m *module) getTopPodGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostablePods,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -182,26 +172,12 @@ func (m *module) getTopPodGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.PodNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.PodNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToPodsQueryNames[orderByKey]
@@ -247,19 +223,10 @@ func (m *module) getTopPodGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
@@ -269,11 +236,7 @@ func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, re
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}
// getPerGroupPodStatusCountsWithReqMetricChecks gates getPerGroupPodStatusCounts
@@ -288,7 +251,6 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByPodStatus []inframonitoringtypes.PodStatus,
) (map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
present, err := m.getMetricsExistence(ctx, podStatusMetricNamesList)
if err != nil {
@@ -314,28 +276,13 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
return map[string]podStatusCounts{}, warning, nil
}
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups, filterByPodStatus)
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
if err != nil {
return nil, nil, err
}
return counts, nil, nil
}
// applyPodStatusFilter adds the display-status push-down (lower(display_status)
// IN (...)) to the outer count builder. valuer lowercases the wire value while
// display_status is kubectl-cased, so we compare lower() on both. No-op when the
// requested set is empty.
func applyPodStatusFilter(cb *sqlbuilder.SelectBuilder, filterByPodStatus []inframonitoringtypes.PodStatus) {
if len(filterByPodStatus) == 0 {
return
}
vals := make([]string, len(filterByPodStatus))
for i, s := range filterByPodStatus {
vals[i] = s.StringValue()
}
cb.Where(cb.In("lower(display_status)", sqlbuilder.List(vals)))
}
// getPerGroupPodStatusCounts computes per-group pod counts bucketed by each
// pod's latest kubectl-style display status in the requested window. Caller
// must ensure the required metrics exist (getPerGroupPodStatusCountsWithReqMetricChecks).
@@ -356,21 +303,13 @@ func (m *module) getPerGroupPodStatusCounts(
filter *qbtypes.Filter,
groupBy []qbtypes.GroupByKey,
pageGroups []map[string]string,
filterByPodStatus []inframonitoringtypes.PodStatus,
) (map[string]podStatusCounts, error) {
// return early if no group by or (no pagegroups provided plus no filterBystatus given for a full scan)
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByPodStatus) == 0) {
if len(pageGroups) == 0 || len(groupBy) == 0 {
return map[string]podStatusCounts{}, nil
}
var (
filterClause *sqlbuilder.WhereClause
err error
userFilterExpr string
)
// Merge user filter with page-groups IN clauses.
userFilterExpr = ""
userFilterExpr := ""
if filter != nil {
userFilterExpr = filter.Expression
}
@@ -383,7 +322,10 @@ func (m *module) getPerGroupPodStatusCounts(
// CTEs, and buildFilterClause hits the metadata store + parses the
// expression, so we don't want to repeat it per CTE. AddWhereClause only
// reads the clause, so the same instance is safe to attach to each builder.
var (
filterClause *sqlbuilder.WhereClause
err error
)
if mergedFilterExpr != "" {
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
if err != nil {
@@ -598,15 +540,11 @@ func (m *module) getPerGroupPodStatusCounts(
countGroupBy = append(countGroupBy, col)
}
countSelectCols = append(countSelectCols, statusCountCols...)
// Outer count query. Built with sqlbuilder so the status push-down uses a
// proper IN (keep only pods whose display status is in the requested set).
countBuilder := sqlbuilder.NewSelectBuilder()
countBuilder.Select(countSelectCols...)
countBuilder.From("pod_status")
applyPodStatusFilter(countBuilder, filterByPodStatus)
countBuilder.GroupBy(countGroupBy...)
countSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
countSQL := fmt.Sprintf(
"SELECT %s FROM pod_status GROUP BY %s",
strings.Join(countSelectCols, ", "),
strings.Join(countGroupBy, ", "),
)
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
cteFragments := []string{
@@ -623,7 +561,7 @@ func (m *module) getPerGroupPodStatusCounts(
phaseFpsArgs, phasePerPodArgs,
podReasonFpsArgs, podReasonPerPodArgs,
containerReasonFpsArgs, containerInnerArgs,
}, countArgs)
}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {

View File

@@ -1,63 +0,0 @@
package implinframonitoring
import (
"reflect"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/huandu/go-sqlbuilder"
)
func TestApplyPodStatusFilter(t *testing.T) {
tests := []struct {
name string
statuses []inframonitoringtypes.PodStatus
wantWhere bool
wantArgs []any
}{
{
name: "empty set yields no clause",
statuses: nil,
wantWhere: false,
wantArgs: nil,
},
{
name: "single status pushes lowercased arg via IN",
statuses: []inframonitoringtypes.PodStatus{inframonitoringtypes.PodStatusRunning},
wantWhere: true,
wantArgs: []any{"running"},
},
{
name: "multiple statuses push lowercased args via IN",
statuses: []inframonitoringtypes.PodStatus{
inframonitoringtypes.PodStatusRunning,
inframonitoringtypes.PodStatusCrashLoopBackOff,
},
wantWhere: true,
wantArgs: []any{"running", "crashloopbackoff"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cb := sqlbuilder.NewSelectBuilder()
cb.Select("pod_uid")
cb.From("pod_status")
applyPodStatusFilter(cb, tt.statuses)
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
hasWhere := strings.Contains(sql, "lower(display_status) IN (")
if hasWhere != tt.wantWhere {
t.Errorf("applyPodStatusFilter(%v) sql = %q, wantWhere = %v", tt.statuses, sql, tt.wantWhere)
}
if len(tt.wantArgs) == 0 {
if len(args) != 0 {
t.Errorf("applyPodStatusFilter(%v) args = %v, want none", tt.statuses, args)
}
} else if !reflect.DeepEqual(args, tt.wantArgs) {
t.Errorf("applyPodStatusFilter(%v) args = %v, want %v", tt.statuses, args, tt.wantArgs)
}
})
}
}

View File

@@ -82,34 +82,20 @@ func buildStatefulSetRecords(
return records
}
// getTopStatefulSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
func (m *module) getTopStatefulSetGroupsAndMetadata(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableStatefulSets,
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
) ([]map[string]string, map[string]map[string]string, error) {
var (
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
statusCounts map[string]podStatusCounts
statusWarning *qbtypes.QueryWarnData
filter *qbtypes.Filter
filterByPodStatus []inframonitoringtypes.PodStatus
orderByKey string
metadataMap map[string]map[string]string
allMetricGroups []rankedGroup
)
orderByKey = req.OrderBy.Key.Name
// When filtering by pod status, resolve the full-scope status keyset
// concurrently (pageGroups=nil spans all groups under the user filter) so it
// can intersect metadata + ranked groups below.
if req.Filter != nil {
filter = &req.Filter.Filter
filterByPodStatus = req.Filter.FilterByPodStatus
}
g, gCtx := errgroup.WithContext(ctx)
g.Go(func() error {
@@ -118,26 +104,12 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
return err
})
if len(filterByPodStatus) != 0 {
g.Go(func() error {
var err error
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
return err
})
}
if orderByKey == inframonitoringtypes.StatefulSetNameAttrKey {
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
}
// Secondary filter: keep only status-matching groups. A missing metric
// yields an empty statusCounts, so this correctly empties the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
metadataMap = intersectMap(metadataMap, statusCounts)
return nil, nil, err
}
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.StatefulSetNameAttrKey)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return pageGroups, metadataMap, nil
}
queryNamesForOrderBy := orderByToStatefulSetsQueryNames[orderByKey]
@@ -183,19 +155,10 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
})
if err := g.Wait(); err != nil {
return nil, nil, nil, nil, err
return nil, nil, err
}
// Secondary filter: intersect ranked groups + metadata with the status keyset.
// A missing metric yields an empty statusCounts, correctly emptying the result
// (the caller also surfaces the warning).
if len(filterByPodStatus) != 0 {
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
metadataMap = intersectMap(metadataMap, statusCounts)
}
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
return pageGroups, metadataMap, statusCounts, statusWarning, nil
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
}
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
@@ -205,9 +168,5 @@ func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
var filter *qbtypes.Filter
if req.Filter != nil {
filter = &req.Filter.Filter
}
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}

View File

@@ -232,6 +232,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
sqlmigration.NewFillDashboardSpecCollectionsFactory(sqlstore, dashboardStore),
)
}

View File

@@ -0,0 +1,124 @@
package sqlmigration
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
// Required, non-nullable v2 spec fields, mapped to their empty value.
var nullableSpecCollections = map[string]any{
"variables": []any{},
"panels": map[string]any{},
"layouts": []any{},
}
type fillDashboardSpecCollections struct {
sqlstore sqlstore.SQLStore
dashboardStore dashboardtypes.Store
settings factory.ProviderSettings
}
func NewFillDashboardSpecCollectionsFactory(sqlstore sqlstore.SQLStore, dashboardStore dashboardtypes.Store) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("fill_dashboard_spec_collections"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fillDashboardSpecCollections{sqlstore: sqlstore, dashboardStore: dashboardStore, settings: ps}, nil
},
)
}
func (migration *fillDashboardSpecCollections) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up replaces a missing or null spec.variables / spec.panels / spec.layouts with the
// empty collection. One transaction; v1 dashboards are skipped.
func (migration *fillDashboardSpecCollections) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, id := range orgIDs {
orgID, err := valuer.NewUUID(id)
if err != nil {
return err
}
if err := migration.fillOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
// fillOrg fills every v2 dashboard in the org that needs it, inside the caller's transaction.
func (migration *fillDashboardSpecCollections) fillOrg(ctx context.Context, orgID valuer.UUID) error {
// List, not ListV2: ListV2 paginates and excludes system dashboards; a migration needs every row.
storables, err := migration.dashboardStore.List(ctx, orgID)
if err != nil {
return err
}
logger := migration.settings.Logger
var stillInV1, malformedSpec, skippedNoNulls, migrated int
for _, storable := range storables {
if !storable.IsV2() {
stillInV1++
continue
}
// Raw data, not ToDashboardV2: decoding validates, and these are the rows it rejects.
spec, ok := storable.Data["spec"].(map[string]any)
if !ok {
malformedSpec++
logger.WarnContext(ctx, "v2 dashboard has no spec object; leaving it untouched", slog.String("org_id", orgID.String()), slog.String("dashboard_id", storable.ID.String()))
continue
}
if !fillSpecCollections(spec) {
skippedNoNulls++
continue
}
if err := migration.dashboardStore.Update(ctx, orgID, storable); err != nil {
return err
}
migrated++
}
logger.InfoContext(ctx, "filled required collections on v2 dashboards",
slog.String("org_id", orgID.String()),
slog.Int("total", len(storables)),
slog.Int("still_in_v1", stillInV1),
slog.Int("malformed_spec", malformedSpec),
slog.Int("skipped_no_nulls", skippedNoNulls),
slog.Int("migrated", migrated),
)
return nil
}
// fillSpecCollections empties each absent or null required collection, reporting whether
// anything changed. A present value is left alone whatever its shape, so a malformed one
// still surfaces as a validation error.
func fillSpecCollections(spec map[string]any) bool {
changed := false
for field, empty := range nullableSpecCollections {
if value, present := spec[field]; !present || value == nil {
spec[field] = empty
changed = true
}
}
return changed
}
func (migration *fillDashboardSpecCollections) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -2303,6 +2303,15 @@ func unionTemporalities(existing, additional []metrictypes.Temporality) []metric
return existing
}
// resolveMetricType applies the non-monotonic-cumulative-sum-as-gauge rule.
// Monotonicity is only meaningful for cumulative sums; delta sums always stay Sum.
func resolveMetricType(metricType metrictypes.Type, isMonotonic bool, temporality metrictypes.Temporality) metrictypes.Type {
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
return metrictypes.GaugeType
}
return metricType
}
func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, tableName string, adjustedStartTs, adjustedEndTs uint64, metricNames []string, extraConds ...string) (map[string][]metrictypes.Temporality, map[string]metrictypes.Type, error) {
temporalities := make(map[string][]metrictypes.Temporality)
types := make(map[string]metrictypes.Type)
@@ -2339,9 +2348,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
if temporality != metrictypes.Unknown {
temporalities[metricName] = append(temporalities[metricName], temporality)
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
types[metricName] = metricType
}
if err := rows.Err(); err != nil {
@@ -2392,9 +2399,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")
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
temporalities[metricName] = temporality
types[metricName] = metricType
}

View File

@@ -0,0 +1,64 @@
package telemetrymetadata
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/stretchr/testify/assert"
)
func TestResolveMetricType(t *testing.T) {
testCases := []struct {
description string
inputMetricType metrictypes.Type
inputIsMonotonic bool
inputTemporality metrictypes.Temporality
expectedMetricType metrictypes.Type
}{
{
description: "delta non-monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "cumulative non-monotonic sum becomes a gauge",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.GaugeType,
},
{
description: "cumulative monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.SumType,
},
{
description: "delta monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "gauge is unaffected by monotonicity",
inputMetricType: metrictypes.GaugeType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Unspecified,
expectedMetricType: metrictypes.GaugeType,
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(
t,
testCase.expectedMetricType,
resolveMetricType(testCase.inputMetricType, testCase.inputIsMonotonic, testCase.inputTemporality),
)
})
}
}

View File

@@ -22,7 +22,8 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
updatedAt := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC)
spec := DashboardSpec{
Display: Display{Name: "Test Dashboard"},
Display: Display{Name: "Test Dashboard"},
Variables: []Variable{},
Panels: map[string]*Panel{
"p1": {
Kind: "Panel",

View File

@@ -63,8 +63,13 @@ func (d *DashboardSpec) Validate() error {
return d.validateLayouts()
}
// validateVariables rejects two variables sharing the same name.
// validateVariables rejects an absent or null list, and duplicate variable names.
func (d *DashboardSpec) validateVariables() error {
// Nil is an absent or explicitly null field; `[]` decodes non-nil. The schema
// declares it required and non-nullable, so both are rejected.
if d.Variables == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.variables: is required and must not be null; use [] for a dashboard with no variables")
}
seen := make(map[string]struct{}, len(d.Variables))
for i, v := range d.Variables {
var name string
@@ -94,6 +99,9 @@ func (d *DashboardSpec) validateVariables() error {
}
func (d *DashboardSpec) validatePanels() error {
if d.Panels == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels: is required and must not be null; use {} for a dashboard with no panels")
}
for key, panel := range d.Panels {
if err := common.ValidateID(key); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "spec.panels: %s", err.Error())
@@ -252,6 +260,9 @@ const maxLayoutsPerDashboard = 500
// Geometry (validateGridLayoutGeometry) needs only each layout's own data but
// runs here so its errors can name the layout by index.
func (d *DashboardSpec) validateLayouts() error {
if d.Layouts == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: is required and must not be null; use [] for a dashboard with no layouts")
}
if len(d.Layouts) > maxLayoutsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.layouts: dashboard has %d layouts; maximum is %d", len(d.Layouts), maxLayoutsPerDashboard)
}

View File

@@ -47,6 +47,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
// UnmarshalJSON methods (panel/query/variable plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -77,14 +78,15 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels no links
data := []byte(`{}`)
// The three required collections must be present, but may be empty.
data := []byte(`{"variables": [], "panels": {}, "layouts": []}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
}
func TestValidateOnlyVariables(t *testing.T) {
data := []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -116,8 +118,60 @@ func TestValidateOnlyVariables(t *testing.T) {
assert.NoError(t, err, "expected valid")
}
// TestInvalidateAbsentOrNullRequiredCollections pins the strict reading of the
// schema on the three required, non-nullable collections: an absent key breaks
// `required`, an explicit null breaks the array/object type, and both are
// rejected. Only the empty collection is accepted.
func TestInvalidateAbsentOrNullRequiredCollections(t *testing.T) {
cases := []struct {
description string
specJSON string
expectedPath string
}{
{
description: "variables absent",
specJSON: `{"panels": {}, "layouts": []}`,
expectedPath: "spec.variables",
},
{
description: "variables null",
specJSON: `{"variables": null, "panels": {}, "layouts": []}`,
expectedPath: "spec.variables",
},
{
description: "panels absent",
specJSON: `{"variables": [], "layouts": []}`,
expectedPath: "spec.panels",
},
{
description: "panels null",
specJSON: `{"variables": [], "panels": null, "layouts": []}`,
expectedPath: "spec.panels",
},
{
description: "layouts absent",
specJSON: `{"variables": [], "panels": {}}`,
expectedPath: "spec.layouts",
},
{
description: "layouts null",
specJSON: `{"variables": [], "panels": {}, "layouts": null}`,
expectedPath: "spec.layouts",
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
_, err := unmarshalDashboard([]byte(c.specJSON))
require.Error(t, err)
assert.Contains(t, err.Error(), c.expectedPath+": is required and must not be null")
})
}
}
func TestInvalidateDuplicateVariableNames(t *testing.T) {
data := []byte(`{
"panels": {},
"variables": [
{
"kind": "TextVariable",
@@ -147,6 +201,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
listVarWithName := func(name string) []byte {
return []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -187,6 +242,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
func TestInvalidatePanelKey(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"bad key!": {
"kind": "Panel",
@@ -213,6 +269,7 @@ func TestInvalidatePanelKey(t *testing.T) {
func TestInvalidateListVariableCrossFields(t *testing.T) {
listVar := func(specFields string) []byte {
return []byte(`{
"panels": {},
"variables": [
{
"kind": "ListVariable",
@@ -295,11 +352,13 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"panels": {},
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"links": [],
"layouts": []
}`),
"list variable": []byte(`{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -331,6 +390,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown panel plugin",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -348,6 +408,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown panel envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Row",
@@ -364,6 +425,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown query plugin",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -387,6 +449,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown query envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -410,6 +473,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "empty query envelope kind",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -433,6 +497,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
{
name: "unknown variable plugin",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -460,6 +525,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
func TestInvalidateOneInvalidPanel(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"good": {
"kind": "Panel",
@@ -495,7 +561,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
return []byte(`{"variables": [], ` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
@@ -547,6 +613,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in panel spec",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -567,6 +634,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in query spec",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -593,6 +661,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
{
name: "unknown field in variable spec",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -630,6 +699,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on panel plugin field",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -650,6 +720,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on query plugin field",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -676,6 +747,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
{
name: "wrong type on variable plugin field",
data: `{
"panels": {},
"variables": [{
"kind": "ListVariable",
"spec": {
@@ -715,6 +787,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad signal in builder query",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -744,6 +817,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad line interpolation",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -764,6 +838,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad line style",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -784,6 +859,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad fill mode",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -804,6 +880,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad spanGaps fillLessThan",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -824,6 +901,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad time preference",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -844,6 +922,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad legend position",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -864,6 +943,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad legend mode",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -884,6 +964,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad threshold format",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -904,6 +985,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad comparison operator",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -924,6 +1006,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
{
name: "bad precision",
data: `{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -964,6 +1047,7 @@ func TestThresholdLabelOptional(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -989,6 +1073,7 @@ func TestThresholdLabelOptional(t *testing.T) {
func TestInvalidatePanelWithoutQueries(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1005,6 +1090,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1027,6 +1113,7 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
// signoz/CompositeQuery, not by listing multiple top-level queries.
func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1136,6 +1223,7 @@ func TestValidateRequiredFields(t *testing.T) {
func TestTimeSeriesPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1188,6 +1276,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1251,6 +1340,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
// then unmarshal it back (what would be read from DB), and verify defaults survive.
func TestStorageRoundTrip(t *testing.T) {
input := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
@@ -1336,7 +1426,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
const validSpec = `"spec": {"variables": [], "panels": {}, "layouts": [], "links": []}`
tests := []struct {
scenario string
@@ -1348,13 +1438,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}{
{
scenario: "flag true with display.name derives name on conversion",
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
wantName: "",
wantDisplay: "My Dashboard!",
},
{
scenario: "flag true with non-empty name is rejected",
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"variables":[],"panels":{},"layouts":[],"links":[]}}`,
wantErr: true,
wantErrMatch: "name must be empty when generateName is true",
},
@@ -1513,6 +1603,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
}
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
@@ -1524,6 +1615,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
@@ -1574,6 +1666,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
return []byte(`{
"variables": [],
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
@@ -1694,6 +1787,7 @@ func TestValidateGridItemLimit(t *testing.T) {
// the unmarshal path — it does, via DashboardSpec.Validate -> validateLayouts.
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
@@ -1714,6 +1808,7 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
// two items are side by side so they clear the overlap check first.
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
@@ -1745,39 +1840,43 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
expectedLabel string
}{
{
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"display": {"name": "%s"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
scenario: "dashboard display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{
"variables": [],
"panels": {},"display": {"name": "%s"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
dashboardJSONFmt: `{"variables": [], "panels": {"p1": {"kind": "Panel", "spec": {"links": [], "display": {"name": "%s"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "%s"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
limit: MaxDisplayNameLen,
dashboardJSONFmt: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
dashboardJSONFmt: `{"panels": {}, "variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "%s"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
scenario: "layout title",
limit: MaxLayoutTitleLen,
dashboardJSONFmt: `{
"variables": [],
"panels": {},"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "%s"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
}
@@ -1797,7 +1896,9 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
_, err := unmarshalDashboard([]byte(`{
"variables": [],
"panels": {},"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -179,6 +179,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
normalizePreV5GroupBy(q)
normalizePreV5PageSize(q, rowLimitPanel)
normalizeQueryLimit(q)
normalizeQueryOffset(q)
if needsAggregation {
ensureDefaultAggregation(q)
}
@@ -198,6 +199,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
assignMissingFormulaNames(formulas)
for _, f := range formulas {
normalizePreV5QueryData(f, widgetType, panelKind)
normalizeQueryLimit(f)
name := d.readString(f, "queryName")
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
backfillFormulaFields(env, f)
@@ -219,6 +221,8 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
normalizePreV5QueryData(op, widgetType, panelKind)
normalizePreV5GroupBy(op)
normalizeOrderByKeys(op)
normalizeQueryLimit(op)
normalizeQueryOffset(op)
name := d.readString(op, "queryName")
out = append(out, traceOperatorEnvelope(name, expression, op))
}

View File

@@ -618,15 +618,32 @@ func normalizePreV5PageSize(query map[string]any, rowLimitPanel bool) {
}
}
// normalizeQueryLimit drops a limit above the v5 maximum (MaxQueryLimit); v1 allowed
// larger/unbounded limits, and an over-max value fails validation. Removing it leaves
// the query unlimited (the field is optional).
// normalizeQueryLimit coerces limit to the int the v5 decode expects: v1 stored it
// as a string ("5") or float, both of which fail the typed decode. An unparseable
// value or one above the v5 maximum (MaxQueryLimit) is dropped, leaving the query
// unlimited (the field is optional).
func normalizeQueryLimit(query map[string]any) {
limit, ok := coerceFloat(query["limit"])
if !ok {
if query["limit"] == nil {
return
}
if limit > qb.MaxQueryLimit {
limit, ok := coerceFloat(query["limit"])
if !ok || limit > qb.MaxQueryLimit {
delete(query, "limit")
return
}
query["limit"] = int(limit)
}
// normalizeQueryOffset coerces offset to the int the v5 decode expects; v1 could
// store it as a string. An unparseable value is dropped (offset defaults to 0).
func normalizeQueryOffset(query map[string]any) {
if query["offset"] == nil {
return
}
offset, ok := coerceFloat(query["offset"])
if !ok {
delete(query, "offset")
return
}
query["offset"] = int(offset)
}

View File

@@ -1,4 +1,5 @@
{
"variables": [],
"display": {
"name": "NV dashboard with sections",
"description": ""

View File

@@ -42,22 +42,13 @@ type ClusterRecord struct {
type PostableClusters struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *ClusterFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// ClusterFilter is the attribute filter plus optional secondary filters on the
// derived pod display status(es) (see PodStatus; matches any listed, OR) and node
// readiness (see NodeCondition). Empty FilterByPodStatus / FilterByNodeReadiness = off.
type ClusterFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
FilterByNodeReadiness NodeCondition `json:"filterByNodeReadiness"`
}
// Validate ensures PostableClusters contains acceptable values.
func (req *PostableClusters) Validate() error {
if req == nil {
@@ -97,18 +88,6 @@ func (req *PostableClusters) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.Filter != nil && !req.Filter.FilterByNodeReadiness.IsZero() && !IsFilterableNodeCondition(req.Filter.FilterByNodeReadiness) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by node readiness: %s", req.Filter.FilterByNodeReadiness)
}
if req.OrderBy != nil {
if !slices.Contains(ClustersValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -36,20 +36,13 @@ type DaemonSetRecord struct {
type PostableDaemonSets struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *DaemonSetFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// DaemonSetFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type DaemonSetFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableDaemonSets contains acceptable values.
func (req *PostableDaemonSets) Validate() error {
if req == nil {
@@ -89,14 +82,6 @@ func (req *PostableDaemonSets) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(DaemonSetsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -34,20 +34,13 @@ type DeploymentRecord struct {
type PostableDeployments struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *DeploymentFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// DeploymentFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type DeploymentFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableDeployments contains acceptable values.
func (req *PostableDeployments) Validate() error {
if req == nil {
@@ -87,14 +80,6 @@ func (req *PostableDeployments) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(DeploymentsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -36,20 +36,13 @@ type JobRecord struct {
type PostableJobs struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *JobFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// JobFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type JobFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableJobs contains acceptable values.
func (req *PostableJobs) Validate() error {
if req == nil {
@@ -89,14 +82,6 @@ func (req *PostableJobs) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(JobsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -34,20 +34,13 @@ type NamespaceRecord struct {
type PostableNamespaces struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *NamespaceFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// NamespaceFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type NamespaceFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableNamespaces contains acceptable values.
func (req *PostableNamespaces) Validate() error {
if req == nil {
@@ -87,14 +80,6 @@ func (req *PostableNamespaces) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(NamespacesValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -39,22 +39,13 @@ type NodeRecord struct {
type PostableNodes struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *NodeFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// NodeFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus; matches any listed, OR) and node
// readiness (see NodeCondition). Empty FilterByPodStatus / FilterByNodeReadiness = off.
type NodeFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
FilterByNodeReadiness NodeCondition `json:"filterByNodeReadiness"`
}
// Validate ensures PostableNodes contains acceptable values.
func (req *PostableNodes) Validate() error {
if req == nil {
@@ -94,18 +85,6 @@ func (req *PostableNodes) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.Filter != nil && !req.Filter.FilterByNodeReadiness.IsZero() && !IsFilterableNodeCondition(req.Filter.FilterByNodeReadiness) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by node readiness: %s", req.Filter.FilterByNodeReadiness)
}
if req.OrderBy != nil {
if !slices.Contains(NodesValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -1,10 +1,6 @@
package inframonitoringtypes
import (
"slices"
"github.com/SigNoz/signoz/pkg/valuer"
)
import "github.com/SigNoz/signoz/pkg/valuer"
type NodeCondition struct {
valuer.String
@@ -24,12 +20,6 @@ func (NodeCondition) Enum() []any {
}
}
// IsFilterableNodeCondition reports whether c is a concrete, user-filterable
// node readiness: any Enum() member except the no_data sentinel.
func IsFilterableNodeCondition(c NodeCondition) bool {
return c != NodeConditionNoData && slices.Contains((NodeCondition{}).Enum(), any(c))
}
// Numeric values emitted by the k8s.node.condition_ready metric
// (source: OTel kubeletstats receiver).
const (

View File

@@ -63,20 +63,13 @@ type PodRecord struct {
type PostablePods struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *PodFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// PodFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type PodFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostablePods contains acceptable values.
func (req *PostablePods) Validate() error {
if req == nil {
@@ -116,14 +109,6 @@ func (req *PostablePods) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(PodsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -1,10 +1,6 @@
package inframonitoringtypes
import (
"slices"
"github.com/SigNoz/signoz/pkg/valuer"
)
import "github.com/SigNoz/signoz/pkg/valuer"
// PodStatus is the kubectl-style pod display status, derived from
// k8s.pod.phase + k8s.pod.status_reason + k8s.container.status.reason
@@ -70,12 +66,6 @@ func (PodStatus) Enum() []any {
}
}
// IsFilterablePodStatus reports whether s is a concrete, user-filterable pod
// status: any Enum() member except the no_data sentinel.
func IsFilterablePodStatus(s PodStatus) bool {
return s != PodStatusNoData && slices.Contains((PodStatus{}).Enum(), any(s))
}
const PodNameAttrKey = "k8s.pod.name"
const (

View File

@@ -34,20 +34,13 @@ type StatefulSetRecord struct {
type PostableStatefulSets struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *StatefulSetFilter `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// StatefulSetFilter is the attribute filter plus an optional secondary filter on the
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
type StatefulSetFilter struct {
qbtypes.Filter `json:",inline"`
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
}
// Validate ensures PostableStatefulSets contains acceptable values.
func (req *PostableStatefulSets) Validate() error {
if req == nil {
@@ -87,14 +80,6 @@ func (req *PostableStatefulSets) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.Filter != nil {
for _, s := range req.Filter.FilterByPodStatus {
if !IsFilterablePodStatus(s) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
}
}
}
if req.OrderBy != nil {
if !slices.Contains(StatefulSetsValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)

View File

@@ -88,15 +88,3 @@
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-ss-uid","k8s.pod.name":"nd-ss-pod","k8s.statefulset.name":"nd-ss","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-ss-uid","k8s.pod.name":"nd-ss-pod","k8s.statefulset.name":"nd-ss","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-ss-uid","k8s.pod.name":"nd-ss-pod","k8s.statefulset.name":"nd-ss","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-uid", "k8s.pod.name": "nd-ds-p1", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-uid", "k8s.pod.name": "nd-ds-p1", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-uid", "k8s.pod.name": "nd-ds-p1", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nd-ds-p1-clbo-uid", "k8s.pod.name": "nd-ds-p1-clbo", "k8s.daemonset.name": "nd-ds", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -94,15 +94,3 @@
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nj-ss-uid","k8s.pod.name":"nj-ss-pod","k8s.statefulset.name":"nj-ss","k8s.namespace.name":"ns-nj","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nj-ss-uid","k8s.pod.name":"nj-ss-pod","k8s.statefulset.name":"nj-ss","k8s.namespace.name":"ns-nj","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nj-ss-uid","k8s.pod.name":"nj-ss-pod","k8s.statefulset.name":"nj-ss","k8s.namespace.name":"ns-nj","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-uid", "k8s.pod.name": "nj-job-p1", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-uid", "k8s.pod.name": "nj-job-p1", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-uid", "k8s.pod.name": "nj-job-p1", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "nj-job-p1-clbo-uid", "k8s.pod.name": "nj-job-p1-clbo", "k8s.job.name": "nj-job", "k8s.namespace.name": "ns-nj", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -67,15 +67,3 @@
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-dep-uid","k8s.pod.name":"nd-dep-pod","k8s.deployment.name":"nd-dep","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:00:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-dep-uid","k8s.pod.name":"nd-dep-pod","k8s.deployment.name":"nd-dep","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:02:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name":"k8s.pod.phase","labels":{"k8s.pod.uid":"nd-dep-uid","k8s.pod.name":"nd-dep-pod","k8s.deployment.name":"nd-dep","k8s.namespace.name":"ns-nd","k8s.cluster.name":"cluster-x"},"timestamp":"2025-01-10T10:04:00+00:00","value":2,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-uid", "k8s.pod.name": "ns-ss-p1", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:00:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-uid", "k8s.pod.name": "ns-ss-p1", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:02:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-uid", "k8s.pod.name": "ns-ss-p1", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.phase", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 2, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.pod.status_reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 6, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}
{"metric_name": "k8s.container.status.reason", "labels": {"k8s.pod.uid": "ns-ss-p1-clbo-uid", "k8s.pod.name": "ns-ss-p1-clbo", "k8s.statefulset.name": "ns-ss", "k8s.namespace.name": "ns-nd", "k8s.cluster.name": "cluster-x", "k8s.container.name": "app", "k8s.container.status.reason": "CrashLoopBackOff"}, "timestamp": "2025-01-10T10:04:00+00:00", "value": 1, "temporality": "Unspecified", "type_": "Gauge", "is_monotonic": false}

View File

@@ -34,7 +34,7 @@ def test_create_and_get_public_dashboard(
json={
"schemaVersion": "v6",
"name": "sample-title",
"spec": {"display": {"name": "Sample Title"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Sample Title"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {admin_token}"},

View File

@@ -91,7 +91,7 @@ def test_create_rejects_non_dns_name(
json={
"schemaVersion": "v6",
"name": "Not A Label",
"spec": {"display": {"name": "Not A Label"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Not A Label"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -114,7 +114,7 @@ def test_create_rejects_unknown_field(
json={
"schemaVersion": "v6",
"name": "rejects-unknown",
"spec": {"display": {"name": "Rejects Unknown"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Unknown"}, "links": []},
"tags": [],
"unknownfield": "boom",
},
@@ -139,7 +139,7 @@ def test_create_rejects_reserved_tag_key(
json={
"schemaVersion": "v6",
"name": "rejects-reserved",
"spec": {"display": {"name": "Rejects Reserved"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Rejects Reserved"}},
"tags": [{"key": "source", "value": "x"}],
},
headers={"Authorization": f"Bearer {token}"},
@@ -163,7 +163,7 @@ def test_create_rejects_too_many_tags(
json={
"schemaVersion": "v6",
"name": "too-many-tags",
"spec": {"display": {"name": "Too Many"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Too Many"}},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -187,7 +187,7 @@ def test_create_rejects_long_display_name(
json={
"schemaVersion": "v6",
"name": "long-display-name",
"spec": {"display": {"name": "x" * 129}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "x" * 129}},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
@@ -205,6 +205,8 @@ def test_create_rejects_long_display_name(
"schemaVersion": "v6",
"name": "long-layout-title",
"spec": {
"variables": [],
"panels": {},
"display": {"name": "Long Layout Title"},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"display": {"title": "x" * 257}, "items": []}}],
@@ -234,6 +236,8 @@ def test_create_rejects_all_value_without_multiselect(
"schemaVersion": "v6",
"name": "all-without-multi",
"spec": {
"panels": {},
"layouts": [],
"display": {"name": "All Without Multi"},
"links": [],
"variables": [
@@ -302,6 +306,7 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-overlap",
"spec": {
"variables": [],
"display": {"name": "Rejects Overlap"},
"panels": {"p1": panel("P1"), "p2": panel("P2")},
"layouts": [
@@ -334,6 +339,7 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-multiref",
"spec": {
"variables": [],
"display": {"name": "Rejects Multiref"},
"panels": {"p1": panel("P1")},
"layouts": [
@@ -366,6 +372,8 @@ def test_create_rejects_invalid_grid_layout(
"schemaVersion": "v6",
"name": "rejects-too-many-items",
"spec": {
"variables": [],
"panels": {},
"display": {"name": "Rejects Too Many"},
"layouts": [
{
@@ -457,7 +465,7 @@ def test_update_rejects_malformed_id(
json={
"schemaVersion": "v6",
"name": "malformed-id",
"spec": {"display": {"name": "Malformed Id"}},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Malformed Id"}},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -479,7 +487,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Missing Dashboard"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -675,7 +683,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -1037,7 +1045,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Alpha Overview"}, "links": []},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -1081,7 +1089,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Beta Overview"}, "links": []},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1185,7 +1193,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": created_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1211,7 +1219,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": reordered},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1240,7 +1248,7 @@ def test_dashboard_v2_tag_order_round_trips(
]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
json={"schemaVersion": "v6", "name": "tag-order", "spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "Tag Order"}, "links": []}, "tags": new_order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -1277,7 +1285,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": f"Pin Limit {i}"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1375,7 +1383,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": display}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1465,6 +1473,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-builder",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-builder"},
"links": [],
"panels": {
@@ -1516,6 +1526,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-ch-promql",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-ch-promql"},
"links": [],
"panels": {
@@ -1581,6 +1593,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-promql",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-promql"},
"links": [],
"panels": {
@@ -1626,6 +1640,8 @@ def test_dashboard_v2_get_by_metric_name(
"schemaVersion": "v6",
"name": "by-metric-false-positive",
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "by-metric-false-positive"},
"links": [],
"panels": {
@@ -1757,6 +1773,8 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
"name": f"agg-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Aggregation"},
"links": [],
"panels": {
@@ -1850,6 +1868,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"name": "roundtrip-zero-values",
"tags": [],
"spec": {
"layouts": [],
"display": {"name": "Roundtrip Zero Values", "description": ""},
"duration": "",
"refreshInterval": "",
@@ -2089,6 +2108,8 @@ def test_dashboard_v2_omitted_enums_apply_defaults(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Enum"},
"panels": {
"ts": {
@@ -2196,6 +2217,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"variables": [],
"layouts": [],
"display": {"name": "Enum"},
"panels": {
"p": {
@@ -2225,6 +2248,8 @@ def test_dashboard_v2_rejects_explicit_empty_enum(
"name": f"enum-{uuid.uuid4().hex[:8]}",
"tags": [],
"spec": {
"panels": {},
"layouts": [],
"display": {"name": "Enum"},
"variables": [
{

View File

@@ -10,11 +10,7 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import (
STATUS_BUCKETS,
STATUS_TO_BUCKET,
expected_status_counts,
)
from fixtures.inframonitoring import STATUS_BUCKETS, STATUS_TO_BUCKET
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
from fixtures.time import parse_timestamp
@@ -596,16 +592,6 @@ def test_pods_orderby( # pylint: disable=too-many-arguments,too-many-positional
"is only allowed when groupBy is empty",
id="orderby_podname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
pytest.param(
{"filter": {"filterByPodStatus": ["running", "Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid_member",
),
],
)
def test_pods_validation_errors(
@@ -711,44 +697,6 @@ def test_pods_status_list_mode(
if other != bucket:
assert rec["podCountsByStatus"][other] == 0, f"expected {other}=0 when status={expected_status}, got {rec['podCountsByStatus']}"
# filterByPodStatus (secondary filter, applied after status is assigned):
# a set containing the pod's status keeps it; a set without it filters it
# out. Wire value is case-insensitive (valuer lowercases). Multi-select is
# OR: [own_status, other] still keeps the pod.
for variant in ([expected_status], [expected_status.upper()], [expected_status, "running"]):
matched = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": f"k8s.pod.name = '{pod_name}'", "filterByPodStatus": variant},
},
timeout=5,
)
assert matched.status_code == HTTPStatus.OK, matched.text
mdata = matched.json()["data"]
assert mdata["total"] == 1, f"filterByPodStatus={variant!r} should keep {pod_name}"
assert mdata["records"][0]["podCountsByStatus"][bucket] == 1
# None of the seeded pods resolves to Running or OOMKilled -> reliable
# mismatches, as a single value and as an all-absent multi-select set.
for fbps in (["running"], ["running", "oomKilled"]):
filtered_out = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": f"k8s.pod.name = '{pod_name}'", "filterByPodStatus": fbps},
},
timeout=5,
)
assert filtered_out.status_code == HTTPStatus.OK, filtered_out.text
assert filtered_out.json()["data"]["total"] == 0, f"{pod_name} must be filtered out by filterByPodStatus={fbps!r}"
@pytest.mark.parametrize(
"pod_name,expected_restarts",
@@ -929,61 +877,6 @@ def test_pods_status_grouped_mode(
)
assert rec["podCountsByStatus"] == expected_counts
# filterByPodStatus in grouped mode: the group is kept because >=1 pod
# matches, and only the filtered buckets are populated (others zeroed).
running = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
"filter": {"filterByPodStatus": ["running"]},
},
timeout=5,
)
assert running.status_code == HTTPStatus.OK, running.text
rdata = running.json()["data"]
assert rdata["total"] == 1
assert rdata["records"][0]["podCountsByStatus"] == expected_status_counts(running=2)
# Multi-select is OR: both requested buckets populated (union), others zeroed.
multi = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
"filter": {"filterByPodStatus": ["running", "crashLoopBackOff"]},
},
timeout=5,
)
assert multi.status_code == HTTPStatus.OK, multi.text
mdata = multi.json()["data"]
assert mdata["total"] == 1
assert mdata["records"][0]["podCountsByStatus"] == expected_status_counts(running=2, crashLoopBackOff=1)
# A set fully absent from the group -> group dropped, empty page (single
# and multi-select both).
for fbps in (["oomKilled"], ["oomKilled", "unknown"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [{"name": "k8s.namespace.name", "fieldDataType": "string", "fieldContext": "resource"}],
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"group must be dropped by filterByPodStatus={fbps!r}"
def test_pods_restarts_grouped_mode(
signoz: types.SigNoz,
@@ -1073,25 +966,3 @@ def test_pods_status_missing_metric_warning(
for bucket in STATUS_BUCKETS:
assert rec["podCountsByStatus"][bucket] == 0, f"expected {bucket}=0 when gated off, got {rec['podCountsByStatus']}"
assert rec["podRestarts"] == -1
# filterByPodStatus + missing status metric: the up-front gate surfaces the
# warning and returns an empty page (Total 0) instead of silently filtering
# everything out.
filtered = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.pod.name = 'miss-p1'", "filterByPodStatus": ["running"]},
},
timeout=5,
)
assert filtered.status_code == HTTPStatus.OK, filtered.text
fdata = filtered.json()["data"]
assert fdata["total"] == 0
assert fdata["records"] == []
fwarn = fdata.get("warning") or {}
fmsgs = ([fwarn["message"]] if fwarn.get("message") else []) + [w["message"] for w in fwarn.get("warnings", [])]
assert any("Pod status could not be computed" in m for m in fmsgs), f"gate warning missing on filtered call: {fmsgs!r}"

View File

@@ -10,7 +10,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import expected_status_counts
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
@@ -313,115 +312,6 @@ def test_nodes_filter_invalid(
assert any(err_substr in e["message"] for e in body["error"]["errors"]), f"{err_substr!r} not surfaced: {body['error']['errors']!r}"
def test_nodes_filter_by_pod_status(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""filterByPodStatus on nodes: a node is kept when >=1 of its pods matches
the requested display status, and podCountsByStatus reflects only that
status (others 0); an absent status yields an empty page. Reuses
clusters_pod_phases.jsonl (carries k8s.node.name + full status metrics):
pp-node has running=3, crashLoopBackOff=1, error=1, evicted=1, pending=1."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/clusters_pod_phases.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Multi-select is OR -> the union of requested buckets (others zeroed).
for fbps, expected in (
(["running"], expected_status_counts(running=3)),
(["CrashLoopBackOff"], expected_status_counts(crashLoopBackOff=1)),
(["running", "CrashLoopBackOff"], expected_status_counts(running=3, crashLoopBackOff=1)),
):
resp = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = resp.json()["data"]
assert data["total"] == 1
rec = data["records"][0]
assert rec["nodeName"] == "pp-node"
assert rec["podCountsByStatus"] == expected
# A set fully absent from the node -> empty page (single and multi).
for fbps in (["completed"], ["completed", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"node must be dropped by filterByPodStatus={fbps!r}"
# Combined filterByPodStatus + filterByNodeReadiness = AND. pp-node is Ready
# and runs pods -> kept when both match; dropped if either side has no match.
both_match = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": "running", "filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert both_match.status_code == HTTPStatus.OK, both_match.text
bdata = both_match.json()["data"]
assert bdata["total"] == 1
assert bdata["records"][0]["nodeName"] == "pp-node"
# readiness side fails (pp-node is not not_ready) -> dropped.
readiness_fails = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": "running", "filterByNodeReadiness": "not_ready"},
},
timeout=5,
)
assert readiness_fails.status_code == HTTPStatus.OK, readiness_fails.text
assert readiness_fails.json()["data"]["total"] == 0
# pod-status side fails (no completed pod) -> dropped.
status_fails = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": "completed", "filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert status_fails.status_code == HTTPStatus.OK, status_fails.text
assert status_fails.json()["data"]["total"] == 0
@pytest.mark.parametrize(
"node_name,expected_condition",
[
@@ -470,39 +360,6 @@ def test_nodes_condition_list_mode(
else:
assert rec["nodeCountsByReadiness"] == {"ready": 0, "notReady": 1}
# filterByNodeReadiness (secondary filter): matching readiness keeps the node,
# the opposite readiness filters it out.
matched = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": f"k8s.node.name = '{node_name}'", "filterByNodeReadiness": expected_condition},
},
timeout=5,
)
assert matched.status_code == HTTPStatus.OK, matched.text
mdata = matched.json()["data"]
assert mdata["total"] == 1
assert mdata["records"][0]["nodeName"] == node_name
opposite = "not_ready" if expected_condition == "ready" else "ready"
dropped = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": f"k8s.node.name = '{node_name}'", "filterByNodeReadiness": opposite},
},
timeout=5,
)
assert dropped.status_code == HTTPStatus.OK, dropped.text
assert dropped.json()["data"]["total"] == 0
def test_nodes_condition_latest_wins(
signoz: types.SigNoz,
@@ -594,42 +451,6 @@ def test_nodes_condition_grouped_mode(
# meta surfaces the groupBy key.
assert rec["meta"].get("k8s.cluster.name") == "cluster-mixed"
# filterByNodeReadiness in grouped mode: the group is kept (>=1 matching
# node) and only the filtered bucket is populated.
ready = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [{"name": "k8s.cluster.name", "fieldDataType": "string", "fieldContext": "resource"}],
"filter": {"filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert ready.status_code == HTTPStatus.OK, ready.text
rdata = ready.json()["data"]
assert rdata["total"] == 1
assert rdata["records"][0]["nodeCountsByReadiness"] == {"ready": 2, "notReady": 0}
not_ready = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"groupBy": [{"name": "k8s.cluster.name", "fieldDataType": "string", "fieldContext": "resource"}],
"filter": {"filterByNodeReadiness": "not_ready"},
},
timeout=5,
)
assert not_ready.status_code == HTTPStatus.OK, not_ready.text
ndata = not_ready.json()["data"]
assert ndata["total"] == 1
assert ndata["records"][0]["nodeCountsByReadiness"] == {"ready": 0, "notReady": 1}
@pytest.mark.parametrize(
"group_key,expected",
@@ -861,21 +682,6 @@ def test_nodes_orderby( # pylint: disable=too-many-arguments,too-many-positiona
"is only allowed when groupBy is empty",
id="orderby_nodename_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
pytest.param(
{"filter": {"filterByNodeReadiness": "bogus"}},
"invalid filter by node readiness",
id="filter_by_node_readiness_invalid",
),
pytest.param(
{"filter": {"filterByNodeReadiness": "notready"}},
"invalid filter by node readiness",
id="filter_by_node_readiness_missing_underscore",
),
],
)
def test_nodes_validation_errors(

View File

@@ -10,7 +10,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import expected_status_counts
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
@@ -310,67 +309,6 @@ def test_namespaces_filter_invalid(
assert any(err_substr in e["message"] for e in body["error"]["errors"]), f"{err_substr!r} not surfaced: {body['error']['errors']!r}"
def test_namespaces_filter_by_pod_status(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token,
insert_metrics,
) -> None:
"""filterByPodStatus on namespaces: a namespace is kept when >=1 of its pods
matches the requested display status, and podCountsByStatus reflects only
that status (others 0); an absent status yields an empty page. Reuses
clusters_pod_phases.jsonl (carries k8s.namespace.name + full status metrics):
ns-x has running=3, crashLoopBackOff=1, error=1, evicted=1, pending=1."""
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
Metrics.load_from_file(
get_testdata_file_path("inframonitoring/clusters_pod_phases.jsonl"),
base_time=now - timedelta(minutes=4),
)
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Multi-select is OR -> the union of requested buckets (others zeroed).
for fbps, expected in (
(["running"], expected_status_counts(running=3)),
(["CrashLoopBackOff"], expected_status_counts(crashLoopBackOff=1)),
(["running", "CrashLoopBackOff"], expected_status_counts(running=3, crashLoopBackOff=1)),
):
resp = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = resp.json()["data"]
assert data["total"] == 1
rec = data["records"][0]
assert rec["namespaceName"] == "ns-x"
assert rec["podCountsByStatus"] == expected
# A set fully absent from the namespace -> empty page (single and multi).
for fbps in (["completed"], ["completed", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"namespace must be dropped by filterByPodStatus={fbps!r}"
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
"namespaceCPU",
@@ -659,11 +597,6 @@ def test_namespaces_orderby( # pylint: disable=too-many-arguments,too-many-posi
"is only allowed when groupBy is empty",
id="orderby_nsname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
],
)
def test_namespaces_validation_errors(

View File

@@ -363,36 +363,6 @@ def test_clusters_node_readiness_aggregation(
assert rec["clusterName"] == "rn-cluster"
assert rec["nodeCountsByReadiness"] == {"ready": 3, "notReady": 2}
# filterByNodeReadiness: cluster kept (>=1 matching node), only the filtered
# bucket populated.
ready = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'rn-cluster'", "filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert ready.status_code == HTTPStatus.OK, ready.text
assert ready.json()["data"]["records"][0]["nodeCountsByReadiness"] == {"ready": 3, "notReady": 0}
not_ready = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'rn-cluster'", "filterByNodeReadiness": "not_ready"},
},
timeout=5,
)
assert not_ready.status_code == HTTPStatus.OK, not_ready.text
assert not_ready.json()["data"]["records"][0]["nodeCountsByReadiness"] == {"ready": 0, "notReady": 2}
def test_clusters_pod_status_aggregation(
signoz: types.SigNoz,
@@ -434,95 +404,6 @@ def test_clusters_pod_status_aggregation(
# All status metrics present -> gate satisfied -> no status warning.
assert all("Pod status could not be computed" not in w["message"] for w in get_all_warnings(response.json()))
# filterByPodStatus: cluster kept (>=1 matching pod), only the filtered
# buckets populated. Multi-select is OR -> the union of requested buckets.
for fbps, expected in (
(["running"], expected_status_counts(running=3)),
(["CrashLoopBackOff"], expected_status_counts(crashLoopBackOff=1)),
(["running", "CrashLoopBackOff"], expected_status_counts(running=3, crashLoopBackOff=1)),
):
resp = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'pp-cluster'", "filterByPodStatus": fbps},
},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
fdata = resp.json()["data"]
assert fdata["total"] == 1
assert fdata["records"][0]["podCountsByStatus"] == expected
# A set fully absent from the cluster -> empty page (single and multi).
for fbps in (["completed"], ["completed", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'pp-cluster'", "filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"cluster must be dropped by filterByPodStatus={fbps!r}"
# Combined filterByPodStatus + filterByNodeReadiness = AND. pp-cluster has a
# Ready node and Running pods -> kept when both match; dropped if either side
# has no match.
both_match = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'pp-cluster'", "filterByPodStatus": "running", "filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert both_match.status_code == HTTPStatus.OK, both_match.text
bdata = both_match.json()["data"]
assert bdata["total"] == 1
assert bdata["records"][0]["podCountsByStatus"] == expected_status_counts(running=3)
assert bdata["records"][0]["nodeCountsByReadiness"] == {"ready": 1, "notReady": 0}
# readiness side fails (no not_ready node) -> dropped.
readiness_fails = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'pp-cluster'", "filterByPodStatus": "running", "filterByNodeReadiness": "not_ready"},
},
timeout=5,
)
assert readiness_fails.status_code == HTTPStatus.OK, readiness_fails.text
assert readiness_fails.json()["data"]["total"] == 0
# pod-status side fails (no completed pod) -> dropped.
status_fails = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.cluster.name = 'pp-cluster'", "filterByPodStatus": "completed", "filterByNodeReadiness": "ready"},
},
timeout=5,
)
assert status_fails.status_code == HTTPStatus.OK, status_fails.text
assert status_fails.json()["data"]["total"] == 0
@pytest.mark.parametrize(
"group_key,expected",
@@ -748,21 +629,6 @@ def test_clusters_orderby( # pylint: disable=too-many-arguments,too-many-positi
"is only allowed when groupBy is empty",
id="orderby_clustername_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
pytest.param(
{"filter": {"filterByNodeReadiness": "bogus"}},
"invalid filter by node readiness",
id="filter_by_node_readiness_invalid",
),
pytest.param(
{"filter": {"filterByNodeReadiness": "notready"}},
"invalid filter by node readiness",
id="filter_by_node_readiness_missing_underscore",
),
],
)
def test_clusters_validation_errors(

View File

@@ -379,45 +379,6 @@ def test_deployments_pod_status_aggregation(
# All status metrics present -> gate satisfied -> no status warning.
assert all("Pod status could not be computed" not in w["message"] for w in get_all_warnings(response.json()))
# filterByPodStatus: deployment kept (>=1 matching pod), only the filtered
# buckets populated. Multi-select is OR -> the union of requested buckets.
for fbps, expected in (
(["running"], expected_status_counts(running=3)),
(["CrashLoopBackOff"], expected_status_counts(crashLoopBackOff=1)),
(["running", "CrashLoopBackOff"], expected_status_counts(running=3, crashLoopBackOff=1)),
):
resp = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.deployment.name = 'pp-dep'", "filterByPodStatus": fbps},
},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
fdata = resp.json()["data"]
assert fdata["total"] == 1
assert fdata["records"][0]["podCountsByStatus"] == expected
# A set fully absent from the deployment -> empty page (single and multi).
for fbps in (["completed"], ["completed", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"expression": "k8s.deployment.name = 'pp-dep'", "filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"deployment must be dropped by filterByPodStatus={fbps!r}"
def test_deployments_desired_available_counts(
signoz: types.SigNoz,
@@ -824,11 +785,6 @@ def test_deployments_orderby( # pylint: disable=too-many-arguments,too-many-pos
"is only allowed when groupBy is empty",
id="orderby_depname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
],
)
def test_deployments_validation_errors(

View File

@@ -10,7 +10,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import expected_status_counts
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
@@ -329,77 +328,6 @@ def test_statefulsets_base_filter_drops_non_statefulset_pods(
# No empty-name group leaking through.
assert all(r["statefulSetName"] != "" for r in data["records"])
# filterByPodStatus: ns-ss (ns-ss-p1 Running + ns-ss-p1-clbo CrashLoopBackOff)
# is kept when >=1 pod matches; counts reflect only the filtered status; an
# absent status yields an empty page. Metric aggregation stays undistorted.
unfiltered_cpu = rec["statefulSetCPU"]
running = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running"]},
},
timeout=5,
)
assert running.status_code == HTTPStatus.OK, running.text
rdata = running.json()["data"]
assert rdata["total"] == 1
rrec = rdata["records"][0]
assert rrec["statefulSetName"] == "ns-ss"
assert rrec["podCountsByStatus"] == expected_status_counts(running=1)
assert compare_values(rrec["statefulSetCPU"], unfiltered_cpu, 1e-6), "filterByPodStatus distorted statefulSetCPU"
clbo = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["CrashLoopBackOff"]},
},
timeout=5,
)
assert clbo.status_code == HTTPStatus.OK, clbo.text
cdata = clbo.json()["data"]
assert cdata["total"] == 1
assert cdata["records"][0]["podCountsByStatus"] == expected_status_counts(crashLoopBackOff=1)
# Multi-select is OR: both requested buckets populated (union), others zeroed.
multi = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running", "CrashLoopBackOff"]},
},
timeout=5,
)
assert multi.status_code == HTTPStatus.OK, multi.text
assert multi.json()["data"]["records"][0]["podCountsByStatus"] == expected_status_counts(running=1, crashLoopBackOff=1)
# A set fully absent from the group -> empty page (single and multi-select).
for fbps in (["pending"], ["pending", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"group must be dropped by filterByPodStatus={fbps!r}"
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
@@ -729,11 +657,6 @@ def test_statefulsets_orderby( # pylint: disable=too-many-arguments,too-many-po
"is only allowed when groupBy is empty",
id="orderby_ssname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
],
)
def test_statefulsets_validation_errors(

View File

@@ -10,7 +10,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import expected_status_counts
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
@@ -461,77 +460,6 @@ def test_jobs_base_filter_drops_non_job_pods(
assert rec["jobName"] == "nj-job"
assert all(r["jobName"] != "" for r in data["records"])
# filterByPodStatus: nj-job (nj-job-p1 Running + nj-job-p1-clbo CrashLoopBackOff)
# is kept when >=1 pod matches; counts reflect only the filtered status; an
# absent status yields an empty page. Metric aggregation stays undistorted.
unfiltered_cpu = rec["jobCPU"]
running = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running"]},
},
timeout=5,
)
assert running.status_code == HTTPStatus.OK, running.text
rdata = running.json()["data"]
assert rdata["total"] == 1
rrec = rdata["records"][0]
assert rrec["jobName"] == "nj-job"
assert rrec["podCountsByStatus"] == expected_status_counts(running=1)
assert compare_values(rrec["jobCPU"], unfiltered_cpu, 1e-6), "filterByPodStatus distorted jobCPU"
clbo = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["CrashLoopBackOff"]},
},
timeout=5,
)
assert clbo.status_code == HTTPStatus.OK, clbo.text
cdata = clbo.json()["data"]
assert cdata["total"] == 1
assert cdata["records"][0]["podCountsByStatus"] == expected_status_counts(crashLoopBackOff=1)
# Multi-select is OR: both requested buckets populated (union), others zeroed.
multi = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running", "CrashLoopBackOff"]},
},
timeout=5,
)
assert multi.status_code == HTTPStatus.OK, multi.text
assert multi.json()["data"]["records"][0]["podCountsByStatus"] == expected_status_counts(running=1, crashLoopBackOff=1)
# A set fully absent from the group -> empty page (single and multi-select).
for fbps in (["pending"], ["pending", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"group must be dropped by filterByPodStatus={fbps!r}"
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
@@ -868,11 +796,6 @@ def test_jobs_orderby( # pylint: disable=too-many-arguments,too-many-positional
"is only allowed when groupBy is empty",
id="orderby_jobname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
],
)
def test_jobs_validation_errors(

View File

@@ -10,7 +10,6 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.inframonitoring import expected_status_counts
from fixtures.metrics import Metrics
from fixtures.querier import compare_values, get_all_warnings
@@ -336,77 +335,6 @@ def test_daemonsets_base_filter_drops_non_daemonset_pods(
assert rec["daemonSetName"] == "nd-ds"
assert all(r["daemonSetName"] != "" for r in data["records"])
# filterByPodStatus: nd-ds (nd-ds-p1 Running + nd-ds-p1-clbo CrashLoopBackOff)
# is kept when >=1 pod matches; counts reflect only the filtered status; an
# absent status yields an empty page. Metric aggregation stays undistorted.
unfiltered_cpu = rec["daemonSetCPU"]
running = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running"]},
},
timeout=5,
)
assert running.status_code == HTTPStatus.OK, running.text
rdata = running.json()["data"]
assert rdata["total"] == 1
rrec = rdata["records"][0]
assert rrec["daemonSetName"] == "nd-ds"
assert rrec["podCountsByStatus"] == expected_status_counts(running=1)
assert compare_values(rrec["daemonSetCPU"], unfiltered_cpu, 1e-6), "filterByPodStatus distorted daemonSetCPU"
clbo = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["CrashLoopBackOff"]},
},
timeout=5,
)
assert clbo.status_code == HTTPStatus.OK, clbo.text
cdata = clbo.json()["data"]
assert cdata["total"] == 1
assert cdata["records"][0]["podCountsByStatus"] == expected_status_counts(crashLoopBackOff=1)
# Multi-select is OR: both requested buckets populated (union), others zeroed.
multi = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": ["running", "CrashLoopBackOff"]},
},
timeout=5,
)
assert multi.status_code == HTTPStatus.OK, multi.text
assert multi.json()["data"]["records"][0]["podCountsByStatus"] == expected_status_counts(running=1, crashLoopBackOff=1)
# A set fully absent from the group -> empty page (single and multi-select).
for fbps in (["pending"], ["pending", "oomKilled"]):
absent = requests.post(
signoz.self.host_configs["8080"].get(ENDPOINT),
headers={"authorization": f"Bearer {token}"},
json={
"start": int((now - timedelta(minutes=5)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"limit": 50,
"filter": {"filterByPodStatus": fbps},
},
timeout=5,
)
assert absent.status_code == HTTPStatus.OK, absent.text
assert absent.json()["data"]["total"] == 0, f"group must be dropped by filterByPodStatus={fbps!r}"
# Float record fields compared with tolerance; everything else compared with ==.
_GROUPBY_FLOAT_FIELDS = {
@@ -737,11 +665,6 @@ def test_daemonsets_orderby( # pylint: disable=too-many-arguments,too-many-posi
"is only allowed when groupBy is empty",
id="orderby_dsname_with_groupby",
),
pytest.param(
{"filter": {"filterByPodStatus": ["Bogus"]}},
"invalid filter by pod status",
id="filter_by_pod_status_invalid",
),
],
)
def test_daemonsets_validation_errors(

View File

@@ -0,0 +1,94 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import (
MetricsReducedSampleLast60s,
MetricsReducedSampleSum60s,
MetricsReducedTimeSeries,
)
from fixtures.querier import aligned_epoch, query_metric_values
# Same setup and expected values as 02_reduced_counter, but the metric is a
# delta, non-monotonic Sum. It must still be treated as a Sum (read from the
# sum_60s table), so the values match. The last_60s rows are decoys: a gauge
# misclassification would read them (999.0) instead of the sum_60s counter.
@pytest.mark.parametrize(
"time_agg, expected",
[
# 2 groups x 5 minutes x 30.0 per 300s step
("rate", 1.0), # 300 / 300s
("increase", 300.0),
],
)
def test_delta_nonmonotonic_sum_rate_and_increase(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_reduced_metrics: Callable[..., None],
time_agg: str,
expected: float,
) -> None:
metric_name = f"test_reduction_delta_nonmonotonic_sum_{time_agg}"
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
# delta non-monotonic sum: MetricsReducedTimeSeries keeps the Delta temporality as-is
time_series = [
MetricsReducedTimeSeries(
metric_name=metric_name,
kept_labels={"service": service},
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
for service in ("a", "b")
]
assert all(ts.temporality == "Delta" for ts in time_series)
insert_reduced_metrics(
time_series,
sum_samples=[
MetricsReducedSampleSum60s(
metric_name=metric_name,
reduced_fingerprint=ts.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_value=30.0,
count_series=2,
count_samples=2,
temporality="Delta",
)
for ts in time_series
for minute in range(20)
],
# Decoy last_60s rows: WhichReducedSamplesTableToUse reads this table only
# if the metric is (mis)classified as a Gauge. The 999.0 values are chosen
# to differ from the sum_60s result, so a regression that treats this delta
# sum as a Gauge makes the assertion below fail instead of silently passing.
last_samples=[
MetricsReducedSampleLast60s(
metric_name=metric_name,
reduced_fingerprint=ts.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_last=999.0,
min_value=999.0,
max_value=999.0,
sum_values=999.0,
count_series=2,
count_samples=2,
temporality="Delta",
)
for ts in time_series
for minute in range(20)
],
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
values = query_metric_values(signoz, token, metric_name, base_epoch, base_epoch + 20 * 60, time_agg, "sum", step_interval=300)
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
assert [v["value"] for v in values] == [expected] * 4

View File

@@ -0,0 +1,62 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import build_builder_query, get_series_values, make_query_request
# A delta, non-monotonic Sum queried without an explicit type must be treated as
# a Sum: the server resolves the type and the delta rate/increase values must be
# correct. Non-reduced delta values are temporality-driven, so this is a
# forward-looking guard against a future change routing the delta path by type
# (e.g. gauge -> avg/last).
@pytest.mark.parametrize(
"time_aggregation, expected",
[
("rate", 1.0), # 60 per 60s bucket / 60s
("increase", 60.0),
],
)
def test_delta_nonmonotonic_sum_is_treated_as_sum(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
time_aggregation: str,
expected: float,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=6)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = f"test_delta_nonmonotonic_sum_{time_aggregation}"
metrics = [
Metrics(
metric_name=metric_name,
labels={"service": "a"},
timestamp=now - timedelta(minutes=minute),
value=60.0,
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
for minute in range(1, 6)
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# No type and no temporality: the server resolves both from the seeded series.
query = build_builder_query("A", metric_name, time_aggregation, "sum")
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
values = get_series_values(response.json(), "A")
assert len(values) == 5, f"Expected 5 buckets, got {values}"
for value in values:
assert value["value"] == expected, f"Expected {expected}, got {value['value']}"

View File

@@ -94,7 +94,7 @@ def test_service_account_role_access_admin(
json={
"schemaVersion": "v6",
"name": "admin-sa-dash",
"spec": {"display": {"name": "admin-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "admin-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},
@@ -134,7 +134,7 @@ def test_service_account_role_access_editor(
json={
"schemaVersion": "v6",
"name": "editor-sa-dash",
"spec": {"display": {"name": "editor-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "editor-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},
@@ -174,7 +174,7 @@ def test_service_account_role_access_viewer(
json={
"schemaVersion": "v6",
"name": "viewer-sa-dash",
"spec": {"display": {"name": "viewer-sa-dash"}, "links": []},
"spec": {"variables": [], "panels": {}, "layouts": [], "display": {"name": "viewer-sa-dash"}, "links": []},
"tags": [],
},
headers={"SIGNOZ-API-KEY": api_key},