Compare commits

..

12 Commits

Author SHA1 Message Date
amlannandy
e4a4d0ad93 chore: update types 2026-02-06 14:09:45 +07:00
amlannandy
9cd6eee89a chore: address comments 2026-02-06 14:07:06 +07:00
amlannandy
c4bcc62c11 chore: additional changes 2026-02-06 14:06:28 +07:00
amlannandy
8876153826 chore: fix CI 2026-02-06 14:05:58 +07:00
amlannandy
ce9026349d chore: minor changes 2026-02-06 14:05:52 +07:00
amlannandy
1083a3971d chore: switch to generated apis 2026-02-06 14:04:58 +07:00
amlannandy
9cb29bd9ca chore: fix ci 2026-02-06 14:02:42 +07:00
amlannandy
1446e5051f chore: update tests 2026-02-06 14:02:37 +07:00
amlannandy
a69f7697eb chore: update loading state 2026-02-06 14:01:58 +07:00
amlannandy
10d83a3f5f chore: api migration 2026-02-06 14:01:56 +07:00
amlannandy
1040a756dd chore: add new v2 api func and hooks 2026-02-06 13:59:56 +07:00
Srikanth Chekuri
a0f407a848 chore(metricsexplorer): update tags and regenerate (#10197) 2026-02-06 12:10:11 +05:30
58 changed files with 1622 additions and 7647 deletions

View File

@@ -2665,6 +2665,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2719,6 +2720,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2774,6 +2776,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2940,6 +2943,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -3807,6 +3811,9 @@ components:
type: string
alertName:
type: string
required:
- alertName
- alertId
type: object
MetricsexplorertypesMetricAlertsResponse:
properties:
@@ -3815,6 +3822,8 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricAlert'
nullable: true
type: array
required:
- alerts
type: object
MetricsexplorertypesMetricAttribute:
properties:
@@ -3828,6 +3837,10 @@ components:
type: string
nullable: true
type: array
required:
- key
- values
- valueCount
type: object
MetricsexplorertypesMetricAttributesRequest:
properties:
@@ -3839,6 +3852,8 @@ components:
start:
nullable: true
type: integer
required:
- metricName
type: object
MetricsexplorertypesMetricAttributesResponse:
properties:
@@ -3850,6 +3865,9 @@ components:
totalKeys:
format: int64
type: integer
required:
- attributes
- totalKeys
type: object
MetricsexplorertypesMetricDashboard:
properties:
@@ -3861,6 +3879,11 @@ components:
type: string
widgetName:
type: string
required:
- dashboardName
- dashboardId
- widgetId
- widgetName
type: object
MetricsexplorertypesMetricDashboardsResponse:
properties:
@@ -3869,6 +3892,8 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboard'
nullable: true
type: array
required:
- dashboards
type: object
MetricsexplorertypesMetricHighlightsResponse:
properties:
@@ -3884,6 +3909,11 @@ components:
totalTimeSeries:
minimum: 0
type: integer
required:
- dataPoints
- lastReceived
- totalTimeSeries
- activeTimeSeries
type: object
MetricsexplorertypesMetricMetadata:
properties:
@@ -3892,11 +3922,27 @@ components:
isMonotonic:
type: boolean
temporality:
enum:
- delta
- cumulative
- unspecified
type: string
type:
enum:
- gauge
- sum
- histogram
- summary
- exponentialhistogram
type: string
unit:
type: string
required:
- description
- type
- unit
- temporality
- isMonotonic
type: object
MetricsexplorertypesStat:
properties:
@@ -3911,9 +3957,22 @@ components:
minimum: 0
type: integer
type:
enum:
- gauge
- sum
- histogram
- summary
- exponentialhistogram
type: string
unit:
type: string
required:
- metricName
- description
- type
- unit
- timeseries
- samples
type: object
MetricsexplorertypesStatsRequest:
properties:
@@ -3931,6 +3990,10 @@ components:
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
MetricsexplorertypesStatsResponse:
properties:
@@ -3942,6 +4005,9 @@ components:
total:
minimum: 0
type: integer
required:
- metrics
- total
type: object
MetricsexplorertypesTreemapEntry:
properties:
@@ -3953,6 +4019,10 @@ components:
totalValue:
minimum: 0
type: integer
required:
- metricName
- percentage
- totalValue
type: object
MetricsexplorertypesTreemapRequest:
properties:
@@ -3964,10 +4034,18 @@ components:
limit:
type: integer
mode:
enum:
- timeseries
- samples
type: string
start:
format: int64
type: integer
required:
- start
- end
- limit
- mode
type: object
MetricsexplorertypesTreemapResponse:
properties:
@@ -3981,6 +4059,9 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesTreemapEntry'
nullable: true
type: array
required:
- timeseries
- samples
type: object
MetricsexplorertypesUpdateMetricMetadataRequest:
properties:
@@ -3991,11 +4072,28 @@ components:
metricName:
type: string
temporality:
enum:
- delta
- cumulative
- unspecified
type: string
type:
enum:
- gauge
- sum
- histogram
- summary
- exponentialhistogram
type: string
unit:
type: string
required:
- metricName
- type
- description
- unit
- temporality
- isMonotonic
type: object
PreferencetypesPreference:
properties:

View File

@@ -26,7 +26,6 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is invalid: %v", err)
}
if opts.Rule.RuleType == ruletypes.RuleTypeThreshold {
// create a threshold rule
tr, err := baserules.NewThresholdRule(

View File

@@ -28,8 +28,10 @@ import type {
GatewaytypesPostableIngestionKeyLimitDTO,
GatewaytypesUpdatableIngestionKeyLimitDTO,
GetIngestionKeys200,
GetIngestionKeysParams,
RenderErrorResponseDTO,
SearchIngestionKeys200,
SearchIngestionKeysParams,
UpdateIngestionKeyLimitPathParameters,
UpdateIngestionKeyPathParameters,
} from '../sigNoz.schemas';
@@ -42,35 +44,44 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* This endpoint returns the ingestion keys for a workspace
* @summary Get ingestion keys for workspace
*/
export const getIngestionKeys = (signal?: AbortSignal) => {
export const getIngestionKeys = (
params?: GetIngestionKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys`,
method: 'GET',
params,
signal,
});
};
export const getGetIngestionKeysQueryKey = () => {
return ['getIngestionKeys'] as const;
export const getGetIngestionKeysQueryKey = (
params?: GetIngestionKeysParams,
) => {
return ['getIngestionKeys', ...(params ? [params] : [])] as const;
};
export const getGetIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}) => {
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey();
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getIngestionKeys>>> = ({
signal,
}) => getIngestionKeys(signal);
}) => getIngestionKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
@@ -91,14 +102,17 @@ export type GetIngestionKeysQueryError = RenderErrorResponseDTO;
export function useGetIngestionKeys<
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(options);
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -114,10 +128,11 @@ export function useGetIngestionKeys<
*/
export const invalidateGetIngestionKeys = async (
queryClient: QueryClient,
params?: GetIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetIngestionKeysQueryKey() },
{ queryKey: getGetIngestionKeysQueryKey(params) },
options,
);
@@ -662,35 +677,45 @@ export const useUpdateIngestionKeyLimit = <
* This endpoint returns the ingestion keys for a workspace
* @summary Search ingestion keys for workspace
*/
export const searchIngestionKeys = (signal?: AbortSignal) => {
export const searchIngestionKeys = (
params?: SearchIngestionKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<SearchIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys/search`,
method: 'GET',
params,
signal,
});
};
export const getSearchIngestionKeysQueryKey = () => {
return ['searchIngestionKeys'] as const;
export const getSearchIngestionKeysQueryKey = (
params?: SearchIngestionKeysParams,
) => {
return ['searchIngestionKeys', ...(params ? [params] : [])] as const;
};
export const getSearchIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}) => {
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey();
const queryKey =
queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof searchIngestionKeys>>
> = ({ signal }) => searchIngestionKeys(signal);
> = ({ signal }) => searchIngestionKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
@@ -711,14 +736,17 @@ export type SearchIngestionKeysQueryError = RenderErrorResponseDTO;
export function useSearchIngestionKeys<
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(options);
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -734,10 +762,11 @@ export function useSearchIngestionKeys<
*/
export const invalidateSearchIngestionKeys = async (
queryClient: QueryClient,
params?: SearchIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getSearchIngestionKeysQueryKey() },
{ queryKey: getSearchIngestionKeysQueryKey(params) },
options,
);

View File

@@ -47,7 +47,7 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* @summary Get metric alerts
*/
export const getMetricAlerts = (
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
@@ -66,7 +66,7 @@ export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -103,7 +103,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -128,7 +128,7 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -144,7 +144,7 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
@@ -165,7 +165,7 @@ export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -203,7 +203,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -228,7 +228,7 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -244,7 +244,7 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
@@ -265,7 +265,7 @@ export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -303,7 +303,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -328,7 +328,7 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -526,7 +526,7 @@ export const useGetMetricAttributes = <
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
@@ -547,7 +547,7 @@ export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -585,7 +585,7 @@ export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -610,7 +610,7 @@ export function useGetMetricMetadata<
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(

View File

@@ -650,11 +650,11 @@ export interface MetricsexplorertypesMetricAlertDTO {
/**
* @type string
*/
alertId?: string;
alertId: string;
/**
* @type string
*/
alertName?: string;
alertName: string;
}
export interface MetricsexplorertypesMetricAlertsResponseDTO {
@@ -662,24 +662,24 @@ export interface MetricsexplorertypesMetricAlertsResponseDTO {
* @type array
* @nullable true
*/
alerts?: MetricsexplorertypesMetricAlertDTO[] | null;
alerts: MetricsexplorertypesMetricAlertDTO[] | null;
}
export interface MetricsexplorertypesMetricAttributeDTO {
/**
* @type string
*/
key?: string;
key: string;
/**
* @type integer
* @minimum 0
*/
valueCount?: number;
valueCount: number;
/**
* @type array
* @nullable true
*/
values?: string[] | null;
values: string[] | null;
}
export interface MetricsexplorertypesMetricAttributesRequestDTO {
@@ -691,7 +691,7 @@ export interface MetricsexplorertypesMetricAttributesRequestDTO {
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type integer
* @nullable true
@@ -704,31 +704,31 @@ export interface MetricsexplorertypesMetricAttributesResponseDTO {
* @type array
* @nullable true
*/
attributes?: MetricsexplorertypesMetricAttributeDTO[] | null;
attributes: MetricsexplorertypesMetricAttributeDTO[] | null;
/**
* @type integer
* @format int64
*/
totalKeys?: number;
totalKeys: number;
}
export interface MetricsexplorertypesMetricDashboardDTO {
/**
* @type string
*/
dashboardId?: string;
dashboardId: string;
/**
* @type string
*/
dashboardName?: string;
dashboardName: string;
/**
* @type string
*/
widgetId?: string;
widgetId: string;
/**
* @type string
*/
widgetName?: string;
widgetName: string;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
@@ -736,7 +736,7 @@ export interface MetricsexplorertypesMetricDashboardsResponseDTO {
* @type array
* @nullable true
*/
dashboards?: MetricsexplorertypesMetricDashboardDTO[] | null;
dashboards: MetricsexplorertypesMetricDashboardDTO[] | null;
}
export interface MetricsexplorertypesMetricHighlightsResponseDTO {
@@ -744,74 +744,96 @@ export interface MetricsexplorertypesMetricHighlightsResponseDTO {
* @type integer
* @minimum 0
*/
activeTimeSeries?: number;
activeTimeSeries: number;
/**
* @type integer
* @minimum 0
*/
dataPoints?: number;
dataPoints: number;
/**
* @type integer
* @minimum 0
*/
lastReceived?: number;
lastReceived: number;
/**
* @type integer
* @minimum 0
*/
totalTimeSeries?: number;
totalTimeSeries: number;
}
export enum MetricsexplorertypesMetricMetadataDTOTemporality {
delta = 'delta',
cumulative = 'cumulative',
unspecified = 'unspecified',
}
export enum MetricsexplorertypesMetricMetadataDTOType {
gauge = 'gauge',
sum = 'sum',
histogram = 'histogram',
summary = 'summary',
exponentialhistogram = 'exponentialhistogram',
}
export interface MetricsexplorertypesMetricMetadataDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type boolean
*/
isMonotonic?: boolean;
isMonotonic: boolean;
/**
* @enum delta,cumulative,unspecified
* @type string
*/
temporality: MetricsexplorertypesMetricMetadataDTOTemporality;
/**
* @enum gauge,sum,histogram,summary,exponentialhistogram
* @type string
*/
type: MetricsexplorertypesMetricMetadataDTOType;
/**
* @type string
*/
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export enum MetricsexplorertypesStatDTOType {
gauge = 'gauge',
sum = 'sum',
histogram = 'histogram',
summary = 'summary',
exponentialhistogram = 'exponentialhistogram',
}
export interface MetricsexplorertypesStatDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type integer
* @minimum 0
*/
samples?: number;
samples: number;
/**
* @type integer
* @minimum 0
*/
timeseries?: number;
timeseries: number;
/**
* @enum gauge,sum,histogram,summary,exponentialhistogram
* @type string
*/
type: MetricsexplorertypesStatDTOType;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export interface MetricsexplorertypesStatsRequestDTO {
@@ -819,12 +841,12 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
end?: number;
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit?: number;
limit: number;
/**
* @type integer
*/
@@ -834,7 +856,7 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
start?: number;
start: number;
}
export interface MetricsexplorertypesStatsResponseDTO {
@@ -842,51 +864,56 @@ export interface MetricsexplorertypesStatsResponseDTO {
* @type array
* @nullable true
*/
metrics?: MetricsexplorertypesStatDTO[] | null;
metrics: MetricsexplorertypesStatDTO[] | null;
/**
* @type integer
* @minimum 0
*/
total?: number;
total: number;
}
export interface MetricsexplorertypesTreemapEntryDTO {
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type number
* @format double
*/
percentage?: number;
percentage: number;
/**
* @type integer
* @minimum 0
*/
totalValue?: number;
totalValue: number;
}
export enum MetricsexplorertypesTreemapRequestDTOMode {
timeseries = 'timeseries',
samples = 'samples',
}
export interface MetricsexplorertypesTreemapRequestDTO {
/**
* @type integer
* @format int64
*/
end?: number;
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit?: number;
limit: number;
/**
* @enum timeseries,samples
* @type string
*/
mode?: string;
mode: MetricsexplorertypesTreemapRequestDTOMode;
/**
* @type integer
* @format int64
*/
start?: number;
start: number;
}
export interface MetricsexplorertypesTreemapResponseDTO {
@@ -894,39 +921,53 @@ export interface MetricsexplorertypesTreemapResponseDTO {
* @type array
* @nullable true
*/
samples?: MetricsexplorertypesTreemapEntryDTO[] | null;
samples: MetricsexplorertypesTreemapEntryDTO[] | null;
/**
* @type array
* @nullable true
*/
timeseries?: MetricsexplorertypesTreemapEntryDTO[] | null;
timeseries: MetricsexplorertypesTreemapEntryDTO[] | null;
}
export enum MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality {
delta = 'delta',
cumulative = 'cumulative',
unspecified = 'unspecified',
}
export enum MetricsexplorertypesUpdateMetricMetadataRequestDTOType {
gauge = 'gauge',
sum = 'sum',
histogram = 'histogram',
summary = 'summary',
exponentialhistogram = 'exponentialhistogram',
}
export interface MetricsexplorertypesUpdateMetricMetadataRequestDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type boolean
*/
isMonotonic?: boolean;
isMonotonic: boolean;
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @enum delta,cumulative,unspecified
* @type string
*/
temporality: MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality;
/**
* @enum gauge,sum,histogram,summary,exponentialhistogram
* @type string
*/
type: MetricsexplorertypesUpdateMetricMetadataRequestDTOType;
/**
* @type string
*/
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export interface PreferencetypesPreferenceDTO {
@@ -1851,6 +1892,19 @@ export type GetFeatures200 = {
status?: string;
};
export type GetIngestionKeysParams = {
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type GetIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -1890,6 +1944,24 @@ export type DeleteIngestionKeyLimitPathParameters = {
export type UpdateIngestionKeyLimitPathParameters = {
limitId: string;
};
export type SearchIngestionKeysParams = {
/**
* @type string
* @description undefined
*/
name?: string;
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type SearchIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -1903,7 +1975,7 @@ export type GetMetricAlertsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricAlerts200 = {
@@ -1919,7 +1991,7 @@ export type GetMetricDashboardsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricDashboards200 = {
@@ -1935,7 +2007,7 @@ export type GetMetricHighlightsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricHighlights200 = {
@@ -1962,7 +2034,7 @@ export type GetMetricMetadataParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricMetadata200 = {

View File

@@ -39,10 +39,7 @@ function RelatedMetricsCard({ metric }: RelatedMetricsCardProps): JSX.Element {
dataSource={DataSource.METRICS}
/>
)}
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
<DashboardsAndAlertsPopover metricName={metric.name} />
</div>
);
}

View File

@@ -2,8 +2,8 @@
import { useMemo, useState } from 'react';
import { Card, Input, Select, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { MetricsexplorertypesMetricMetadataDTOType } from 'api/generated/services/sigNoz.schemas';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import classNames from 'classnames';
import { initialQueriesMap } from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters';
@@ -40,8 +40,10 @@ import {
* returns true if the feature flag is enabled, false otherwise
* Show the inspect button in metrics explorer if the feature flag is enabled
*/
export function isInspectEnabled(metricType: MetricType | undefined): boolean {
return metricType === MetricType.GAUGE;
export function isInspectEnabled(
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined,
): boolean {
return metricType === MetricsexplorertypesMetricMetadataDTOType.gauge;
}
export function getAllTimestampsOfMetrics(

View File

@@ -1,8 +1,17 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Collapse, Input, Menu, Popover, Typography } from 'antd';
import {
Button,
Collapse,
Input,
Menu,
Popover,
Skeleton,
Typography,
} from 'antd';
import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent';
import { useGetMetricAttributes } from 'api/generated/services/metrics';
import { ResizeTable } from 'components/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { useNotifications } from 'hooks/useNotifications';
@@ -15,6 +24,8 @@ import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import { AllAttributesProps, AllAttributesValueProps } from './types';
import { getMetricDetailsQuery } from './utils';
const ALL_ATTRIBUTES_KEY = 'all-attributes';
export function AllAttributesValue({
filterKey,
filterValue,
@@ -110,12 +121,31 @@ export function AllAttributesValue({
function AllAttributes({
metricName,
attributes,
metricType,
}: AllAttributesProps): JSX.Element {
const [searchString, setSearchString] = useState('');
const [activeKey, setActiveKey] = useState<string | string[]>(
'all-attributes',
const [activeKey, setActiveKey] = useState<string[]>([ALL_ATTRIBUTES_KEY]);
const {
data: attributesData,
isLoading: isLoadingAttributes,
isError: isErrorAttributes,
mutate: getMetricAttributes,
} = useGetMetricAttributes();
useEffect(() => {
if (metricName) {
getMetricAttributes({
data: {
metricName,
},
});
}
}, [getMetricAttributes, metricName]);
const attributes = useMemo(
() => attributesData?.data?.data?.attributes ?? [],
[attributesData],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
@@ -178,7 +208,7 @@ function AllAttributes({
attributes.filter(
(attribute) =>
attribute.key.toLowerCase().includes(searchString.toLowerCase()) ||
attribute.value.some((value) =>
attribute.values?.some((value) =>
value.toLowerCase().includes(searchString.toLowerCase()),
),
),
@@ -195,7 +225,7 @@ function AllAttributes({
},
value: {
key: attribute.key,
value: attribute.value,
value: attribute.values,
},
}))
: [],
@@ -252,6 +282,12 @@ function AllAttributes({
],
);
const emptyText = useMemo(
() =>
isErrorAttributes ? 'Error fetching attributes' : 'No attributes found',
[isErrorAttributes],
);
const items = useMemo(
() => [
{
@@ -270,6 +306,7 @@ function AllAttributes({
onClick={(e): void => {
e.stopPropagation();
}}
disabled={isLoadingAttributes}
/>
</div>
),
@@ -277,25 +314,37 @@ function AllAttributes({
children: (
<ResizeTable
columns={columns}
loading={isLoadingAttributes}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className="metrics-accordion-content all-attributes-content"
scroll={{ y: 600 }}
locale={{
emptyText,
}}
/>
),
},
],
[columns, tableData, searchString],
[searchString, columns, isLoadingAttributes, tableData, emptyText],
);
if (isLoadingAttributes) {
return (
<div className="all-attributes-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered
className="metrics-accordion metrics-metadata-accordion"
className="metrics-accordion metrics-all-attributes-accordion"
activeKey={activeKey}
onChange={(keys): void => setActiveKey(keys)}
onChange={(keys): void => setActiveKey(keys as string[])}
items={items}
/>
);

View File

@@ -2,36 +2,84 @@ import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { Dropdown, Typography } from 'antd';
import { Skeleton } from 'antd/lib';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import { Bell, Grid } from 'lucide-react';
import { pluralize } from 'utils/pluralize';
import { DashboardsAndAlertsPopoverProps } from './types';
import {
useGetMetricAlerts,
useGetMetricDashboards,
} from 'api/generated/services/metrics';
function DashboardsAndAlertsPopover({
alerts,
dashboards,
metricName,
}: DashboardsAndAlertsPopoverProps): JSX.Element | null {
const { safeNavigate } = useSafeNavigate();
const params = useUrlQuery();
const {
data: alertsData,
isLoading: isLoadingAlerts,
isError: isErrorAlerts,
} = useGetMetricAlerts(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const {
data: dashboardsData,
isLoading: isLoadingDashboards,
isError: isErrorDashboards,
} = useGetMetricDashboards(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const alerts = useMemo(() => {
return alertsData?.data?.data?.alerts ?? [];
}, [alertsData]);
const dashboards = useMemo(() => {
const currentDashboards = dashboardsData?.data?.data?.dashboards ?? [];
// Remove duplicate dashboards
return currentDashboards.filter(
(dashboard, index, self) =>
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
);
}, [dashboardsData]);
const alertsPopoverContent = useMemo(() => {
if (alerts && alerts.length > 0) {
return alerts.map((alert) => ({
key: alert.alert_id,
key: alert.alertId,
label: (
<Typography.Link
key={alert.alert_id}
key={alert.alertId}
onClick={(): void => {
params.set(QueryParams.ruleId, alert.alert_id);
params.set(QueryParams.ruleId, alert.alertId);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
}}
className="dashboards-popover-content-item"
>
{alert.alert_name || alert.alert_id}
{alert.alertName || alert.alertId}
</Typography.Link>
),
}));
@@ -39,41 +87,44 @@ function DashboardsAndAlertsPopover({
return null;
}, [alerts, params]);
const uniqueDashboards = useMemo(
() =>
dashboards?.filter(
(item, index, self) =>
index === self.findIndex((t) => t.dashboard_id === item.dashboard_id),
),
[dashboards],
);
const dashboardsPopoverContent = useMemo(() => {
if (uniqueDashboards && uniqueDashboards.length > 0) {
return uniqueDashboards.map((dashboard) => ({
key: dashboard.dashboard_id,
if (dashboards && dashboards.length > 0) {
return dashboards.map((dashboard) => ({
key: dashboard.dashboardId,
label: (
<Typography.Link
key={dashboard.dashboard_id}
key={dashboard.dashboardId}
onClick={(): void => {
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: dashboard.dashboard_id,
dashboardId: dashboard.dashboardId,
}),
);
}}
className="dashboards-popover-content-item"
>
{dashboard.dashboard_name || dashboard.dashboard_id}
{dashboard.dashboardName || dashboard.dashboardId}
</Typography.Link>
),
}));
}
return null;
}, [uniqueDashboards, safeNavigate]);
}, [dashboards, safeNavigate]);
if (!dashboardsPopoverContent && !alertsPopoverContent) {
return null;
if (isLoadingAlerts || isLoadingDashboards) {
return (
<div className="dashboards-and-alerts-popover-container">
<Skeleton title={false} paragraph={{ rows: 1 }} active />
</div>
);
}
// If there are no dashboards or alerts or both have errors, don't show the popover
const hidePopover =
(!dashboardsPopoverContent && !alertsPopoverContent) ||
(isErrorAlerts && isErrorDashboards);
if (hidePopover) {
return <div className="dashboards-and-alerts-popover-container" />;
}
return (
@@ -92,8 +143,7 @@ function DashboardsAndAlertsPopover({
>
<Grid size={12} color={Color.BG_SIENNA_500} />
<Typography.Text>
{uniqueDashboards?.length} dashboard
{uniqueDashboards?.length === 1 ? '' : 's'}
{pluralize(dashboards.length, 'dashboard')}
</Typography.Text>
</div>
</Dropdown>
@@ -112,7 +162,7 @@ function DashboardsAndAlertsPopover({
>
<Bell size={12} color={Color.BG_SAKURA_500} />
<Typography.Text>
{alerts?.length} alert {alerts?.length === 1 ? 'rule' : 'rules'}
{pluralize(alerts.length, 'alert rule')}
</Typography.Text>
</div>
</Dropdown>

View File

@@ -0,0 +1,129 @@
import { Skeleton, Tooltip, Typography } from 'antd';
import { useMemo } from 'react';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { HighlightsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
} from './utils';
import { useGetMetricHighlights } from 'api/generated/services/metrics';
function Highlights({ metricName }: HighlightsProps): JSX.Element {
const {
data: metricHighlightsData,
isLoading: isLoadingMetricHighlights,
isError: isErrorMetricHighlights,
} = useGetMetricHighlights(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const metricHighlights = useMemo(() => {
return metricHighlightsData?.data?.data ?? null;
}, [metricHighlightsData]);
const dataPoints = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metricHighlights?.dataPoints?.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metricHighlights?.dataPoints ?? 0)}
</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
const timeSeries = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
const timeSeriesActive = formatNumberToCompactFormat(
metricHighlights.activeTimeSeries,
);
const timeSeriesTotal = formatNumberToCompactFormat(
metricHighlights.totalTimeSeries,
);
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
const lastReceived = useMemo(() => {
if (!metricHighlights) {
return null;
}
if (isErrorMetricHighlights) {
return (
<Typography.Text className="metric-details-grid-value">-</Typography.Text>
);
}
const displayText = formatTimestampToReadableDate(
metricHighlights.lastReceived,
);
return (
<Typography.Text className="metric-details-grid-value">
<Tooltip title={displayText}>{displayText}</Tooltip>
</Typography.Text>
);
}, [metricHighlights, isErrorMetricHighlights]);
if (isLoadingMetricHighlights) {
return (
<div className="metric-details-content-grid">
<Skeleton title={false} paragraph={{ rows: 2 }} active />
</div>
);
}
return (
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
{dataPoints}
{timeSeries}
{lastReceived}
</div>
</div>
);
}
export default Highlights;

View File

@@ -1,18 +1,25 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Button, Collapse, Input, Select, Typography } from 'antd';
import { Button, Collapse, Input, Select, Skeleton, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import logEvent from 'api/common/logEvent';
import {
invalidateGetMetricMetadata,
useUpdateMetricMetadata,
} from 'api/generated/services/metrics';
import {
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
import { ResizeTable } from 'components/ResizeTable';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import { useNotifications } from 'hooks/useNotifications';
import { Edit2, Save, X } from 'lucide-react';
@@ -23,23 +30,27 @@ import {
} from '../Summary/constants';
import { MetricTypeRenderer } from '../Summary/utils';
import { METRIC_METADATA_KEYS } from './constants';
import { MetadataProps } from './types';
import { determineIsMonotonic } from './utils';
import { MetadataProps, MetricMetadataState, TableFields } from './types';
import {
transformMetricType,
transformTemporality,
transformUpdateMetricMetadataRequest,
} from './utils';
function Metadata({
metricName,
metadata,
refetchMetricDetails,
isErrorMetricMetadata,
isLoadingMetricMetadata,
}: MetadataProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [
metricMetadata,
setMetricMetadata,
] = useState<UpdateMetricMetadataProps>({
metricType: metadata?.metric_type || MetricType.SUM,
description: metadata?.description || '',
temporality: metadata?.temporality,
unit: metadata?.unit,
const [metricMetadata, setMetricMetadata] = useState<MetricMetadataState>({
type: MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum,
description: '',
temporality:
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified,
unit: '',
});
const { notifications } = useNotifications();
const {
@@ -51,6 +62,18 @@ function Metadata({
);
const queryClient = useQueryClient();
// Initialize state from metadata api data
useEffect(() => {
if (metadata) {
setMetricMetadata({
type: transformMetricType(metadata.type),
description: metadata.description,
temporality: transformTemporality(metadata.temporality),
unit: metadata.unit,
});
}
}, [metadata]);
const tableData = useMemo(
() =>
metadata
@@ -59,7 +82,7 @@ function Metadata({
temporality: metadata?.temporality,
})
// Filter out monotonic as user input is not required
.filter((key) => key !== 'monotonic')
.filter((key) => key !== TableFields.IS_MONOTONIC)
.map((key) => ({
key,
value: {
@@ -72,30 +95,37 @@ function Metadata({
);
// Render un-editable field value
const renderUneditableField = useCallback((key: string, value: string) => {
if (key === 'metric_type') {
return <MetricTypeRenderer type={value as MetricType} />;
}
let fieldValue = value;
if (key === 'unit') {
fieldValue = getUniversalNameFromMetricUnit(value);
}
return <FieldRenderer field={fieldValue || '-'} />;
}, []);
const renderUneditableField = useCallback(
(key: keyof MetricMetadataState, value: string) => {
if (isErrorMetricMetadata) {
return <FieldRenderer field="-" />;
}
if (key === TableFields.TYPE) {
return <MetricTypeRenderer type={value as MetricType} />;
}
let fieldValue = value;
if (key === TableFields.UNIT) {
fieldValue = getUniversalNameFromMetricUnit(value);
}
return <FieldRenderer field={fieldValue || '-'} />;
},
[isErrorMetricMetadata],
);
const renderColumnValue = useCallback(
(field: { value: string; key: string }): JSX.Element => {
(field: { value: string; key: keyof MetricMetadataState }): JSX.Element => {
if (!isEditing) {
return renderUneditableField(field.key, field.value);
}
// Don't allow editing of unit if it's already set
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
const metricUnitAlreadySet =
field.key === TableFields.UNIT && Boolean(metadata?.unit);
if (metricUnitAlreadySet) {
return renderUneditableField(field.key, field.value);
}
if (field.key === 'metric_type') {
if (field.key === TableFields.TYPE) {
return (
<Select
data-testid="metric-type-select"
@@ -103,17 +133,17 @@ function Metadata({
value: key,
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
}))}
value={metricMetadata.metricType}
value={metricMetadata.type}
onChange={(value): void => {
setMetricMetadata((prev) => ({
...prev,
metricType: value as MetricType,
metricType: value,
}));
}}
/>
);
}
if (field.key === 'unit') {
if (field.key === TableFields.UNIT) {
return (
<YAxisUnitSelector
value={metricMetadata.unit}
@@ -125,7 +155,7 @@ function Metadata({
/>
);
}
if (field.key === 'temporality') {
if (field.key === TableFields.Temporality) {
return (
<Select
data-testid="temporality-select"
@@ -137,22 +167,18 @@ function Metadata({
onChange={(value): void => {
setMetricMetadata((prev) => ({
...prev,
temporality: value as Temporality,
temporality: value,
}));
}}
/>
);
}
if (field.key === 'description') {
if (field.key === TableFields.DESCRIPTION) {
return (
<Input
data-testid="description-input"
name={field.key}
defaultValue={
metricMetadata[
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
]
}
defaultValue={metricMetadata.description}
onChange={(e): void => {
setMetricMetadata((prev) => ({
...prev,
@@ -201,18 +227,14 @@ function Metadata({
const handleSave = useCallback(() => {
updateMetricMetadata(
{
metricName,
payload: {
...metricMetadata,
isMonotonic: determineIsMonotonic(
metricMetadata.metricType,
metricMetadata.temporality,
),
pathParams: {
metricName: metricName ?? '',
},
data: transformUpdateMetricMetadataRequest(metricName, metricMetadata),
},
{
onSuccess: (response): void => {
if (response?.statusCode === 200) {
if (response.status === 200) {
logEvent(MetricsExplorerEvents.MetricMetadataUpdated, {
[MetricsExplorerEventKeys.MetricName]: metricName,
[MetricsExplorerEventKeys.Tab]: 'summary',
@@ -221,9 +243,13 @@ function Metadata({
notifications.success({
message: 'Metadata updated successfully',
});
refetchMetricDetails();
setIsEditing(false);
queryClient.invalidateQueries(['metricsList']);
// TODO: To update this to use invalidateGetMetricList
// once we have switched to the V2 API in summary page
queryClient.invalidateQueries([REACT_QUERY_KEY.GET_METRICS_LIST]);
invalidateGetMetricMetadata(queryClient, {
metricName,
});
} else {
notifications.error({
message:
@@ -243,10 +269,24 @@ function Metadata({
metricName,
metricMetadata,
notifications,
refetchMetricDetails,
queryClient,
]);
const cancelEdit = useCallback(
(e: React.MouseEvent<HTMLElement, MouseEvent>): void => {
e.stopPropagation();
if (metadata) {
setMetricMetadata({
type: transformMetricType(metadata.type),
description: metadata.description,
unit: metadata.unit,
});
}
setIsEditing(false);
},
[metadata],
);
const actionButton = useMemo(() => {
if (isEditing) {
return (
@@ -254,10 +294,7 @@ function Metadata({
<Button
className="action-button"
type="text"
onClick={(e): void => {
e.stopPropagation();
setIsEditing(false);
}}
onClick={cancelEdit}
disabled={isUpdatingMetricsMetadata}
>
<X size={14} />
@@ -294,7 +331,7 @@ function Metadata({
</Button>
</div>
);
}, [handleSave, isEditing, isUpdatingMetricsMetadata]);
}, [isEditing, isUpdatingMetricsMetadata, cancelEdit, handleSave]);
const items = useMemo(
() => [
@@ -321,6 +358,14 @@ function Metadata({
[actionButton, columns, tableData],
);
if (isLoadingMetricMetadata) {
return (
<div className="metrics-metadata-skeleton-container">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
);
}
return (
<Collapse
bordered

View File

@@ -39,6 +39,7 @@
gap: 12px;
.metric-details-content-grid {
height: 50px;
.labels-row,
.values-row {
display: grid;
@@ -72,6 +73,7 @@
.dashboards-and-alerts-popover-container {
display: flex;
gap: 16px;
height: 32px;
.dashboards-and-alerts-popover {
border-radius: 20px;
@@ -102,6 +104,14 @@
}
}
.metrics-metadata-skeleton-container {
height: 330px;
}
.all-attributes-skeleton-container {
height: 600px;
}
.metrics-accordion {
.ant-table-body {
&::-webkit-scrollbar {
@@ -148,7 +158,6 @@
.all-attributes-search-input {
width: 300px;
border: 1px solid var(--bg-slate-300);
}
}
@@ -161,6 +170,7 @@
.ant-typography:first-child {
font-family: 'Geist Mono';
color: var(--bg-robin-400);
background-color: transparent;
}
}
.all-attributes-contribution {
@@ -237,6 +247,7 @@
}
.metric-metadata-value {
height: 67px;
background: rgba(22, 25, 34, 0.4);
overflow-x: scroll;
.field-renderer-container {
@@ -330,18 +341,26 @@
.metric-details-content {
.metrics-accordion {
.metrics-accordion-header {
.action-button {
.ant-typography {
color: var(--bg-slate-400);
.action-menu {
.action-button {
.ant-typography {
color: var(--bg-slate-400);
}
}
}
}
.metrics-accordion-content {
.metric-metadata-key {
.field-renderer-container {
.label {
color: var(--bg-slate-300);
}
}
.all-attributes-key {
.ant-typography:last-child {
color: var(--bg-slate-400);
color: var(--bg-vanilla-200);
background-color: var(--bg-robin-300);
}
}

View File

@@ -1,16 +1,8 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
Button,
Divider,
Drawer,
Empty,
Skeleton,
Tooltip,
Typography,
} from 'antd';
import { Button, Divider, Drawer, Empty, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useGetMetricMetadata } from 'api/generated/services/metrics';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, Crosshair, X } from 'lucide-react';
@@ -19,16 +11,12 @@ import ROUTES from '../../../constants/routes';
import { useHandleExplorerTabChange } from '../../../hooks/useHandleExplorerTabChange';
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
import { isInspectEnabled } from '../Inspect/utils';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import AllAttributes from './AllAttributes';
import DashboardsAndAlertsPopover from './DashboardsAndAlertsPopover';
import Highlights from './Highlights';
import Metadata from './Metadata';
import { MetricDetailsProps } from './types';
import {
formatNumberToCompactFormat,
formatTimestampToReadableDate,
getMetricDetailsQuery,
} from './utils';
import { getMetricDetailsQuery } from './utils';
import './MetricDetails.styles.scss';
import '../Summary/Summary.styles.scss';
@@ -43,55 +31,52 @@ function MetricDetails({
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const {
data,
isLoading,
isFetching,
error: metricDetailsError,
refetch: refetchMetricDetails,
} = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const metric = data?.payload?.data;
const lastReceived = useMemo(() => {
if (!metric) {
return null;
}
return formatTimestampToReadableDate(metric.lastReceived);
}, [metric]);
const showInspectFeature = useMemo(
() => isInspectEnabled(metric?.metadata?.metric_type),
[metric],
data: metricMetadataResponse,
isLoading: isLoadingMetricMetadata,
isError: isErrorMetricMetadata,
} = useGetMetricMetadata(
{
metricName: metricName ?? '',
},
{
query: {
enabled: !!metricName,
},
},
);
const isMetricDetailsLoading = isLoading || isFetching;
const timeSeries = useMemo(() => {
if (!metric) {
const metadata = useMemo(() => {
if (
!metricMetadataResponse ||
!metricMetadataResponse.data ||
!metricMetadataResponse.data.data
) {
return null;
}
const timeSeriesActive = formatNumberToCompactFormat(metric.timeSeriesActive);
const timeSeriesTotal = formatNumberToCompactFormat(metric.timeSeriesTotal);
const {
type,
description,
unit,
temporality,
isMonotonic,
} = metricMetadataResponse.data.data;
return (
<Tooltip
title="Active time series are those that have received data points in the last 1
hour."
placement="top"
>
<span>{`${timeSeriesTotal} total ⎯ ${timeSeriesActive} active`}</span>
</Tooltip>
);
}, [metric]);
return {
type,
description,
unit,
temporality,
isMonotonic,
};
}, [metricMetadataResponse]);
const showInspectFeature = useMemo(() => isInspectEnabled(metadata?.type), [
metadata,
]);
const goToMetricsExplorerwithSelectedMetric = useCallback(() => {
if (metricName) {
const compositeQuery = getMetricDetailsQuery(
metricName,
metric?.metadata?.metric_type,
);
const compositeQuery = getMetricDetailsQuery(metricName, metadata?.type);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
{
@@ -107,9 +92,7 @@ function MetricDetails({
[MetricsExplorerEventKeys.Modal]: 'metric-details',
});
}
}, [metricName, handleExplorerTabChange, metric?.metadata?.metric_type]);
const isMetricDetailsError = metricDetailsError || !metric;
}, [metricName, handleExplorerTabChange, metadata?.type]);
useEffect(() => {
logEvent(MetricsExplorerEvents.ModalOpened, {
@@ -117,6 +100,10 @@ function MetricDetails({
});
}, []);
if (!metricName) {
return <Empty description="Metric not found" />;
}
return (
<Drawer
width="60%"
@@ -124,7 +111,7 @@ function MetricDetails({
<div className="metric-details-header">
<div className="metric-details-title">
<Divider type="vertical" />
<Typography.Text>{metric?.name}</Typography.Text>
<Typography.Text>{metricName}</Typography.Text>
</div>
<div className="metric-details-header-buttons">
<Button
@@ -142,8 +129,8 @@ function MetricDetails({
aria-label="Inspect Metric"
icon={<Crosshair size={18} />}
onClick={(): void => {
if (metric?.name) {
openInspectModal(metric.name);
if (metricName) {
openInspectModal(metricName);
}
}}
data-testid="inspect-metric-button"
@@ -163,60 +150,17 @@ function MetricDetails({
destroyOnClose
closeIcon={<X size={16} />}
>
{isMetricDetailsLoading && (
<div data-testid="metric-details-skeleton">
<Skeleton active />
</div>
)}
{isMetricDetailsError && !isMetricDetailsLoading && (
<Empty description="Error fetching metric details" />
)}
{!isMetricDetailsLoading && !isMetricDetailsError && (
<div className="metric-details-content">
<div className="metric-details-content-grid">
<div className="labels-row">
<Typography.Text type="secondary" className="metric-details-grid-label">
SAMPLES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
TIME SERIES
</Typography.Text>
<Typography.Text type="secondary" className="metric-details-grid-label">
LAST RECEIVED
</Typography.Text>
</div>
<div className="values-row">
<Typography.Text className="metric-details-grid-value">
<Tooltip title={metric?.samples.toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric?.samples)}
</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={timeSeries}>{timeSeries}</Tooltip>
</Typography.Text>
<Typography.Text className="metric-details-grid-value">
<Tooltip title={lastReceived}>{lastReceived}</Tooltip>
</Typography.Text>
</div>
</div>
<DashboardsAndAlertsPopover
dashboards={metric.dashboards}
alerts={metric.alerts}
/>
<Metadata
metricName={metric?.name}
metadata={metric.metadata}
refetchMetricDetails={refetchMetricDetails}
/>
{metric.attributes && (
<AllAttributes
metricName={metric?.name}
attributes={metric.attributes}
metricType={metric?.metadata?.metric_type}
/>
)}
</div>
)}
<div className="metric-details-content">
<Highlights metricName={metricName} />
<DashboardsAndAlertsPopover metricName={metricName} />
<Metadata
metricName={metricName}
metadata={metadata}
isErrorMetricMetadata={isErrorMetricMetadata}
isLoadingMetricMetadata={isLoadingMetricMetadata}
/>
<AllAttributes metricName={metricName} metricType={metadata?.type} />
</div>
</Drawer>
);
}

View File

@@ -1,11 +1,13 @@
import * as reactUseHooks from 'react-use';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import { MetricDetailsAttribute } from '../../../../api/metricsExplorer/getMetricDetails';
import ROUTES from '../../../../constants/routes';
import AllAttributes, { AllAttributesValue } from '../AllAttributes';
import { getMockMetricAttributesData } from './testUtlls';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -22,32 +24,30 @@ jest
const mockMetricName = 'test-metric';
const mockMetricType = MetricType.GAUGE;
const mockAttributes: MetricDetailsAttribute[] = [
{
key: 'attribute1',
value: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
value: ['value3'],
valueCount: 1,
},
];
const mockUseCopyToClipboard = jest.fn();
jest
.spyOn(reactUseHooks, 'useCopyToClipboard')
.mockReturnValue([{ value: 'value1' }, mockUseCopyToClipboard] as any);
const useGetMetricAttributesMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAttributes',
);
const mockUseGetMetricAttributes = jest.fn();
describe('AllAttributes', () => {
beforeEach(() => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData(),
mutate: mockUseGetMetricAttributes,
});
});
it('renders attributes section with title', () => {
render(
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
@@ -55,11 +55,7 @@ describe('AllAttributes', () => {
it('renders all attribute keys and values', () => {
render(
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
// Check attribute keys are rendered
@@ -74,11 +70,7 @@ describe('AllAttributes', () => {
it('renders value counts correctly', () => {
render(
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
expect(screen.getByText('2')).toBeInTheDocument(); // For attribute1
@@ -86,41 +78,36 @@ describe('AllAttributes', () => {
});
it('handles empty attributes array', () => {
useGetMetricAttributesMock.mockReturnValue({
...getMockMetricAttributesData({
data: {
attributes: [],
totalKeys: 0,
},
}),
mutate: mockUseGetMetricAttributes,
});
render(
<AllAttributes
metricName={mockMetricName}
attributes={[]}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
expect(screen.queryByText('No data')).toBeInTheDocument();
expect(screen.getByText('No attributes found')).toBeInTheDocument();
});
it('clicking on an attribute key opens the explorer with the attribute filter applied', () => {
it('clicking on an attribute key opens the explorer with the attribute filter applied', async () => {
render(
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
fireEvent.click(screen.getByText('attribute1'));
await userEvent.click(screen.getByText('attribute1'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
});
it('filters attributes based on search input', () => {
it('filters attributes based on search input', async () => {
render(
<AllAttributes
metricName={mockMetricName}
attributes={mockAttributes}
metricType={mockMetricType}
/>,
<AllAttributes metricName={mockMetricName} metricType={mockMetricType} />,
);
fireEvent.change(screen.getByPlaceholderText('Search'), {
target: { value: 'value1' },
});
await userEvent.type(screen.getByPlaceholderText('Search'), 'value1');
expect(screen.getByText('attribute1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
@@ -144,7 +131,7 @@ describe('AllAttributesValue', () => {
expect(screen.getByText('value2')).toBeInTheDocument();
});
it('loads more attributes when show more button is clicked', () => {
it('loads more attributes when show more button is clicked', async () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -155,7 +142,7 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.queryByText('value6')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Show More'));
await userEvent.click(screen.getByText('Show More'));
expect(screen.getByText('value6')).toBeInTheDocument();
});
@@ -172,7 +159,7 @@ describe('AllAttributesValue', () => {
expect(screen.queryByText('Show More')).not.toBeInTheDocument();
});
it('copy button should copy the attribute value to the clipboard', () => {
it('copy button should copy the attribute value to the clipboard', async () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -183,13 +170,13 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
await userEvent.click(screen.getByText('value1'));
expect(screen.getByText('Copy Attribute')).toBeInTheDocument();
fireEvent.click(screen.getByText('Copy Attribute'));
await userEvent.click(screen.getByText('Copy Attribute'));
expect(mockUseCopyToClipboard).toHaveBeenCalledWith('value1');
});
it('explorer button should go to metrics explore with the attribute filter applied', () => {
it('explorer button should go to metrics explore with the attribute filter applied', async () => {
render(
<AllAttributesValue
filterKey="attribute1"
@@ -200,10 +187,10 @@ describe('AllAttributesValue', () => {
/>,
);
expect(screen.getByText('value1')).toBeInTheDocument();
fireEvent.click(screen.getByText('value1'));
await userEvent.click(screen.getByText('value1'));
expect(screen.getByText('Open in Explorer')).toBeInTheDocument();
fireEvent.click(screen.getByText('Open in Explorer'));
await userEvent.click(screen.getByText('Open in Explorer'));
expect(mockGoToMetricsExploreWithAppliedAttribute).toHaveBeenCalledWith(
'attribute1',
'value1',

View File

@@ -1,26 +1,17 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { userEvent } from 'tests/test-utils';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
const mockAlert1 = {
alert_id: '1',
alert_name: 'Alert 1',
};
const mockAlert2 = {
alert_id: '2',
alert_name: 'Alert 2',
};
const mockDashboard1 = {
dashboard_id: '1',
dashboard_name: 'Dashboard 1',
};
const mockDashboard2 = {
dashboard_id: '2',
dashboard_name: 'Dashboard 2',
};
const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
import {
getMockAlertsData,
getMockDashboardsData,
MOCK_ALERT_1,
MOCK_ALERT_2,
MOCK_DASHBOARD_1,
MOCK_DASHBOARD_2,
} from './testUtlls';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
@@ -28,7 +19,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockSetQuery = jest.fn();
const mockUrlQuery = {
set: mockSetQuery,
@@ -39,125 +29,155 @@ jest.mock('hooks/useUrlQuery', () => ({
default: jest.fn(() => mockUrlQuery),
}));
describe('DashboardsAndAlertsPopover', () => {
it('renders the popover correctly with multiple dashboards and alerts', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
const MOCK_METRIC_NAME = 'test-metric';
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
expect(
screen.getByText(`${mockAlerts.length} alert rules`),
).toBeInTheDocument();
const useGetMetricAlertsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricAlerts',
);
const useGetMetricDashboardsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricDashboards',
);
describe('DashboardsAndAlertsPopover', () => {
beforeEach(() => {
useGetMetricAlertsMock.mockReturnValue(getMockAlertsData());
useGetMetricDashboardsMock.mockReturnValue(getMockDashboardsData());
});
it('renders the popover correctly with multiple dashboards and alerts', () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText(`2 dashboards`)).toBeInTheDocument();
expect(screen.getByText(`2 alert rules`)).toBeInTheDocument();
});
it('renders null with no dashboards and alerts', () => {
const { container } = render(
<DashboardsAndAlertsPopover alerts={[]} dashboards={[]} />,
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: undefined,
}),
);
expect(container).toBeEmptyDOMElement();
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: undefined,
}),
);
const { container } = render(
<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />,
);
expect(
container.querySelector('dashboards-and-alerts-popover-container'),
).toBeNull();
});
it('renders popover with single dashboard and alert', () => {
render(
<DashboardsAndAlertsPopover
alerts={[mockAlert1]}
dashboards={[mockDashboard1]}
/>,
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: {
alerts: [MOCK_ALERT_1],
},
}),
);
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1],
},
}),
);
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText(`1 dashboard`)).toBeInTheDocument();
expect(screen.getByText(`1 alert rule`)).toBeInTheDocument();
});
it('renders popover with dashboard id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[{ ...mockDashboard1, dashboard_name: undefined } as any]}
/>,
it('renders popover with dashboard id if name is not available', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [{ ...MOCK_DASHBOARD_1, dashboardName: '' }],
},
}),
);
fireEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(mockDashboard1.dashboard_id)).toBeInTheDocument();
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
await userEvent.click(screen.getByText(`1 dashboard`));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardId)).toBeInTheDocument();
});
it('renders popover with alert id if name is not available', () => {
render(
<DashboardsAndAlertsPopover
alerts={[{ ...mockAlert1, alert_name: undefined } as any]}
dashboards={mockDashboards}
/>,
it('renders popover with alert id if name is not available', async () => {
useGetMetricAlertsMock.mockReturnValue(
getMockAlertsData({
data: {
alerts: [{ ...MOCK_ALERT_1, alertName: '' }],
},
}),
);
fireEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(mockAlert1.alert_id)).toBeInTheDocument();
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
await userEvent.click(screen.getByText(`1 alert rule`));
expect(screen.getByText(MOCK_ALERT_1.alertId)).toBeInTheDocument();
});
it('navigates to the dashboard when the dashboard is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
it('navigates to the dashboard when the dashboard is clicked', async () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
// Click on 2 dashboards button
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
await userEvent.click(screen.getByText(`2 dashboards`));
// Popover showing list of 2 dashboards should be visible
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
// Click on the first dashboard
fireEvent.click(screen.getByText(mockDashboard1.dashboard_name));
await userEvent.click(screen.getByText(MOCK_DASHBOARD_1.dashboardName));
// Should navigate to the dashboard
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${mockDashboard1.dashboard_id}`,
`/dashboard/${MOCK_DASHBOARD_1.dashboardId}`,
);
});
it('navigates to the alert when the alert is clicked', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={mockDashboards}
/>,
);
it('navigates to the alert when the alert is clicked', async () => {
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
// Click on 2 alert rules button
fireEvent.click(screen.getByText(`${mockAlerts.length} alert rules`));
await userEvent.click(screen.getByText(`2 alert rules`));
// Popover showing list of 2 alert rules should be visible
expect(screen.getByText(mockAlert1.alert_name)).toBeInTheDocument();
expect(screen.getByText(mockAlert2.alert_name)).toBeInTheDocument();
expect(screen.getByText(MOCK_ALERT_1.alertName)).toBeInTheDocument();
expect(screen.getByText(MOCK_ALERT_2.alertName)).toBeInTheDocument();
// Click on the first alert rule
fireEvent.click(screen.getByText(mockAlert1.alert_name));
await userEvent.click(screen.getByText(MOCK_ALERT_1.alertName));
// Should navigate to the alert rule
expect(mockSetQuery).toHaveBeenCalledWith(
QueryParams.ruleId,
mockAlert1.alert_id,
MOCK_ALERT_1.alertId,
);
});
it('renders unique dashboards even when there are duplicates', () => {
render(
<DashboardsAndAlertsPopover
alerts={mockAlerts}
dashboards={[...mockDashboards, mockDashboard1]}
/>,
it('renders unique dashboards even when there are duplicates', async () => {
useGetMetricDashboardsMock.mockReturnValue(
getMockDashboardsData({
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
},
}),
);
expect(
screen.getByText(`${mockDashboards.length} dashboards`),
).toBeInTheDocument();
fireEvent.click(screen.getByText(`${mockDashboards.length} dashboards`));
expect(screen.getByText(mockDashboard1.dashboard_name)).toBeInTheDocument();
expect(screen.getByText(mockDashboard2.dashboard_name)).toBeInTheDocument();
render(<DashboardsAndAlertsPopover metricName={MOCK_METRIC_NAME} />);
expect(screen.getByText('2 dashboards')).toBeInTheDocument();
await userEvent.click(screen.getByText('2 dashboards'));
expect(screen.getByText(MOCK_DASHBOARD_1.dashboardName)).toBeInTheDocument();
expect(screen.getByText(MOCK_DASHBOARD_2.dashboardName)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,85 @@
import { render } from '@testing-library/react';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import Highlights from '../Highlights';
import { getMockMetricHighlightsData } from './testUtlls';
import { formatTimestampToReadableDate } from '../utils';
const MOCK_METRIC_NAME = 'test-metric';
const METRIC_DETAILS_GRID_VALUE_SELECTOR = '.metric-details-grid-value';
const useGetMetricHighlightsMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricHighlights',
);
describe('Highlights', () => {
beforeEach(() => {
useGetMetricHighlightsMock.mockReturnValue(getMockMetricHighlightsData());
});
it('should render all highlights data correctly', () => {
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues).toHaveLength(3);
expect(metricHighlightsValues[0].textContent).toBe('1M+');
expect(metricHighlightsValues[1].textContent).toBe('1M total ⎯ 1M active');
expect(metricHighlightsValues[2].textContent).toBe(
formatTimestampToReadableDate('2026-01-24T00:00:00Z'),
);
});
it('should render "-" for highlights data when there is an error', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isError: true,
},
),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues[0].textContent).toBe('-');
expect(metricHighlightsValues[1].textContent).toBe('-');
expect(metricHighlightsValues[2].textContent).toBe('-');
});
it('should render loading state when data is loading', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData(
{},
{
isLoading: true,
},
),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('should not render grid values when there is no data', () => {
useGetMetricHighlightsMock.mockReturnValue(
getMockMetricHighlightsData({
data: undefined,
}),
);
const { container } = render(<Highlights metricName={MOCK_METRIC_NAME} />);
const metricHighlightsValues = container.querySelectorAll(
METRIC_DETAILS_GRID_VALUE_SELECTOR,
);
expect(metricHighlightsValues).toHaveLength(0);
});
});

View File

@@ -1,16 +1,22 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import {
UniversalYAxisUnit,
YAxisUnitSelectorProps,
} from 'components/YAxisUnitSelector/types';
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import * as useNotificationsHooks from 'hooks/useNotifications';
import { userEvent } from 'tests/test-utils';
import { SelectOption } from 'types/common/select';
import Metadata from '../Metadata';
import { MetricMetadata } from '../types';
import { transformMetricMetadata } from '../utils';
import { getMockMetricMetadataData } from './testUtlls';
import { GetMetricMetadata200 } from 'api/generated/services/sigNoz.schemas';
import { AxiosResponse } from 'axios';
// Mock antd select for testing
jest.mock('antd', () => ({
@@ -72,13 +78,18 @@ jest.mock('react-query', () => ({
}),
}));
const mockUseUpdateMetricMetadataHook = jest.spyOn(
metricsExplorerHooks,
'useUpdateMetricMetadata',
);
type UseUpdateMetricMetadataResult = ReturnType<
typeof metricsExplorerHooks.useUpdateMetricMetadata
>;
const mockUseUpdateMetricMetadata = jest.fn();
jest
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
.mockReturnValue({
mutate: mockUseUpdateMetricMetadata,
isLoading: false,
} as any);
const mockMetricMetadata = transformMetricMetadata(
(getMockMetricMetadataData().data as AxiosResponse<GetMetricMetadata200>).data,
) as MetricMetadata;
const mockErrorNotification = jest.fn();
const mockSuccessNotification = jest.fn();
@@ -90,26 +101,26 @@ jest.spyOn(useNotificationsHooks, 'useNotifications').mockReturnValue({
} as any);
const mockMetricName = 'test_metric';
const mockMetricMetadata = {
metric_type: MetricType.GAUGE,
description: 'test_description',
unit: 'test_unit',
temporality: Temporality.DELTA,
};
const mockRefetchMetricDetails = jest.fn();
describe('Metadata', () => {
beforeEach(() => {
mockUseUpdateMetricMetadataHook.mockReturnValue(({
mutate: mockUseUpdateMetricMetadata,
} as Partial<UseUpdateMetricMetadataResult>) as UseUpdateMetricMetadataResult);
});
it('should render the metadata properly', () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
expect(screen.getByText('Metric Type')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metric_type)).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.metricType)).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText(mockMetricMetadata.description)).toBeInTheDocument();
expect(screen.getByText('Unit')).toBeInTheDocument();
@@ -118,18 +129,19 @@ describe('Metadata', () => {
expect(screen.getByText(mockMetricMetadata.temporality)).toBeInTheDocument();
});
it('editing the metadata should show the form inputs', () => {
it('editing the metadata should show the form inputs', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
await userEvent.click(editButton);
expect(screen.getByTestId('metric-type-select')).toBeInTheDocument();
expect(screen.getByTestId('temporality-select')).toBeInTheDocument();
@@ -144,52 +156,47 @@ describe('Metadata', () => {
...mockMetricMetadata,
unit: '',
}}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
expect(metricDescriptionInput).toBeInTheDocument();
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
const metricTypeSelect = screen.getByTestId('metric-type-select');
expect(metricTypeSelect).toBeInTheDocument();
fireEvent.change(metricTypeSelect, {
target: { value: MetricType.SUM },
});
await userEvent.selectOptions(metricTypeSelect, MetricType.SUM);
const temporalitySelect = screen.getByTestId('temporality-select');
expect(temporalitySelect).toBeInTheDocument();
fireEvent.change(temporalitySelect, {
target: { value: Temporality.CUMULATIVE },
});
await userEvent.selectOptions(temporalitySelect, Temporality.CUMULATIVE);
const unitSelect = screen.getByTestId('unit-select');
expect(unitSelect).toBeInTheDocument();
fireEvent.change(unitSelect, {
target: { value: 'By' },
});
await userEvent.selectOptions(unitSelect, 'By');
const saveButton = screen.getByText('Save');
expect(saveButton).toBeInTheDocument();
fireEvent.click(saveButton);
await userEvent.click(saveButton);
expect(mockUseUpdateMetricMetadata).toHaveBeenCalledWith(
expect.objectContaining({
metricName: mockMetricName,
payload: expect.objectContaining({
description: 'Updated description',
metricType: MetricType.SUM,
data: expect.objectContaining({
type: MetricType.SUM,
temporality: Temporality.CUMULATIVE,
unit: 'By',
isMonotonic: true,
}),
pathParams: {
metricName: mockMetricName,
},
}),
expect.objectContaining({
onSuccess: expect.any(Function),
@@ -203,29 +210,28 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await userEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 200 });
onSuccessCallback({ status: 200 });
expect(mockSuccessNotification).toHaveBeenCalledWith({
message: 'Metadata updated successfully',
});
expect(mockRefetchMetricDetails).toHaveBeenCalled();
});
it('should show error notification when metadata update fails with non-200 response', async () => {
@@ -233,24 +239,24 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await userEvent.click(saveButton);
const onSuccessCallback =
mockUseUpdateMetricMetadata.mock.calls[0][1].onSuccess;
onSuccessCallback({ statusCode: 500 });
onSuccessCallback({ status: 500 });
expect(mockErrorNotification).toHaveBeenCalledWith({
message:
@@ -263,20 +269,20 @@ describe('Metadata', () => {
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
fireEvent.click(editButton);
await userEvent.click(editButton);
const metricDescriptionInput = screen.getByTestId('description-input');
fireEvent.change(metricDescriptionInput, {
target: { value: 'Updated description' },
});
await userEvent.clear(metricDescriptionInput);
await userEvent.type(metricDescriptionInput, 'Updated description');
const saveButton = screen.getByText('Save');
fireEvent.click(saveButton);
await userEvent.click(saveButton);
const onErrorCallback = mockUseUpdateMetricMetadata.mock.calls[0][1].onError;
@@ -289,39 +295,41 @@ describe('Metadata', () => {
});
});
it('cancel button should cancel the edit mode', () => {
it('cancel button should cancel the edit mode', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
await userEvent.click(editButton);
const cancelButton = screen.getByText('Cancel');
expect(cancelButton).toBeInTheDocument();
fireEvent.click(cancelButton);
await userEvent.click(cancelButton);
const editButton2 = screen.getByText('Edit');
expect(editButton2).toBeInTheDocument();
});
it('should not allow editing of unit if it is already set', () => {
it('should not allow editing of unit if it is already set', async () => {
render(
<Metadata
metricName={mockMetricName}
metadata={mockMetricMetadata}
refetchMetricDetails={mockRefetchMetricDetails}
isErrorMetricMetadata={false}
isLoadingMetricMetadata={false}
/>,
);
const editButton = screen.getByText('Edit');
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
await userEvent.click(editButton);
const unitSelect = screen.queryByTestId('unit-select');
expect(unitSelect).not.toBeInTheDocument();

View File

@@ -1,68 +1,16 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { render, screen } from '@testing-library/react';
import ROUTES from 'constants/routes';
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import * as useHandleExplorerTabChange from 'hooks/useHandleExplorerTabChange';
import { userEvent } from 'tests/test-utils';
import MetricDetails from '../MetricDetails';
import { getMockMetricMetadataData } from './testUtlls';
const mockMetricName = 'test-metric';
const mockMetricDescription = 'description for a test metric';
const mockMetricData: MetricDetailsType = {
name: mockMetricName,
description: mockMetricDescription,
unit: 'count',
attributes: [
{
key: 'test-attribute',
value: ['test-value'],
valueCount: 1,
},
],
alerts: [],
dashboards: [],
metadata: {
metric_type: MetricType.SUM,
description: mockMetricDescription,
unit: 'count',
},
type: '',
timeseries: 0,
samples: 0,
timeSeriesTotal: 0,
timeSeriesActive: 0,
lastReceived: '',
};
const mockOpenInspectModal = jest.fn();
const mockOnClose = jest.fn();
const mockUseGetMetricDetailsData = {
data: {
payload: {
data: mockMetricData,
},
},
isLoading: false,
isFetching: false,
isError: false,
error: null,
refetch: jest.fn(),
};
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
jest.spyOn(useUpdateMetricMetadata, 'useUpdateMetricMetadata').mockReturnValue({
mutate: jest.fn(),
isLoading: false,
isError: false,
error: null,
} as any);
const mockHandleExplorerTabChange = jest.fn();
jest
.spyOn(useHandleExplorerTabChange, 'useHandleExplorerTabChange')
@@ -88,7 +36,50 @@ jest.mock('react-query', () => ({
}),
}));
jest.mock(
'container/MetricsExplorer/MetricDetails/AllAttributes',
() =>
function MockAllAttributes(): JSX.Element {
return <div data-testid="all-attributes">All Attributes</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/DashboardsAndAlertsPopover',
() =>
function MockDashboardsAndAlertsPopover(): JSX.Element {
return (
<div data-testid="dashboards-and-alerts-popover">
Dashboards and Alerts Popover
</div>
);
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Highlights',
() =>
function MockHighlights(): JSX.Element {
return <div data-testid="highlights">Highlights</div>;
},
);
jest.mock(
'container/MetricsExplorer/MetricDetails/Metadata',
() =>
function MockMetadata(): JSX.Element {
return <div data-testid="metadata">Metadata</div>;
},
);
const useGetMetricMetadataMock = jest.spyOn(
metricsExplorerHooks,
'useGetMetricMetadata',
);
describe('MetricDetails', () => {
beforeEach(() => {
useGetMetricMetadataMock.mockReturnValue(getMockMetricMetadataData());
});
it('renders metric details correctly', () => {
render(
<MetricDetails
@@ -101,27 +92,15 @@ describe('MetricDetails', () => {
);
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
expect(screen.getByTestId('all-attributes')).toBeInTheDocument();
expect(
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
screen.getByTestId('dashboards-and-alerts-popover'),
).toBeInTheDocument();
expect(screen.getByTestId('highlights')).toBeInTheDocument();
expect(screen.getByTestId('metadata')).toBeInTheDocument();
});
it('renders the "open in explorer" and "inspect" buttons', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValueOnce({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
metadata: {
...mockMetricData.metadata,
metric_type: MetricType.GAUGE,
},
},
},
},
} as any);
it('renders the "open in explorer" and "inspect" buttons', async () => {
render(
<MetricDetails
onClose={mockOnClose}
@@ -135,93 +114,24 @@ describe('MetricDetails', () => {
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
expect(screen.getByTestId('inspect-metric-button')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('open-in-explorer-button'));
await userEvent.click(screen.getByTestId('open-in-explorer-button'));
expect(mockHandleExplorerTabChange).toHaveBeenCalled();
fireEvent.click(screen.getByTestId('inspect-metric-button'));
await userEvent.click(screen.getByTestId('inspect-metric-button'));
expect(mockOpenInspectModal).toHaveBeenCalled();
});
it('should render error state when metric details are not found', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isError: true,
error: {
message: 'Error fetching metric details',
},
} as any);
it('should render empty state when metric name is not provided', () => {
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
metricName={null}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('Error fetching metric details')).toBeInTheDocument();
});
it('should render loading state when metric details are loading', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
isLoading: true,
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByTestId('metric-details-skeleton')).toBeInTheDocument();
});
it('should render all attributes section', () => {
jest
.spyOn(useGetMetricDetails, 'useGetMetricDetails')
.mockReturnValue(mockUseGetMetricDetailsData as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.getByText('All Attributes')).toBeInTheDocument();
});
it('should not render all attributes section when relevant data is not present', () => {
jest.spyOn(useGetMetricDetails, 'useGetMetricDetails').mockReturnValue({
...mockUseGetMetricDetailsData,
data: {
payload: {
data: {
...mockMetricData,
attributes: null,
},
},
},
} as any);
render(
<MetricDetails
onClose={mockOnClose}
isOpen
metricName={mockMetricName}
isModalTimeSelection
openInspectModal={mockOpenInspectModal}
/>,
);
expect(screen.queryByText('All Attributes')).not.toBeInTheDocument();
expect(screen.getByText('Metric not found')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,175 @@
import {
GetMetricAlerts200,
GetMetricAttributes200,
GetMetricDashboards200,
GetMetricHighlights200,
GetMetricMetadata200,
} from 'api/generated/services/sigNoz.schemas';
import * as metricsExplorerHooks from 'api/generated/services/metrics';
import { Temporality } from 'types/common/queryBuilder';
export function getMockMetricHighlightsData(
overrides?: Partial<GetMetricHighlights200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights> {
return {
data: {
data: {
data: {
dataPoints: 1000000,
lastReceived: '2026-01-24T00:00:00Z',
totalTimeSeries: 1000000,
activeTimeSeries: 1000000,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricHighlights>;
}
export const MOCK_DASHBOARD_1 = {
dashboardName: 'Dashboard 1',
dashboardId: '1',
widgetId: '1',
widgetName: 'Widget 1',
};
export const MOCK_DASHBOARD_2 = {
dashboardName: 'Dashboard 2',
dashboardId: '2',
widgetId: '2',
widgetName: 'Widget 2',
};
export const MOCK_ALERT_1 = {
alertName: 'Alert 1',
alertId: '1',
};
export const MOCK_ALERT_2 = {
alertName: 'Alert 2',
alertId: '2',
};
export function getMockDashboardsData(
overrides?: Partial<GetMetricDashboards200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
return {
data: {
data: {
data: {
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2],
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
}
export function getMockAlertsData(
overrides?: Partial<GetMetricAlerts200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts> {
return {
data: {
data: {
data: {
alerts: [MOCK_ALERT_1, MOCK_ALERT_2],
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAlerts>;
}
export function getMockMetricAttributesData(
overrides?: Partial<GetMetricAttributes200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes> {
return {
data: {
data: {
data: {
attributes: [
{
key: 'attribute1',
values: ['value1', 'value2'],
valueCount: 2,
},
{
key: 'attribute2',
values: ['value3'],
valueCount: 1,
},
],
totalKeys: 2,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricAttributes>;
}
export function getMockMetricMetadataData(
overrides?: Partial<GetMetricMetadata200>,
{
isLoading = false,
isError = false,
}: {
isLoading?: boolean;
isError?: boolean;
} = {},
): ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata> {
return {
data: {
data: {
data: {
description: 'test_description',
type: 'gauge',
unit: 'test_unit',
temporality: Temporality.Delta,
isMonotonic: false,
},
status: 'success',
...overrides,
},
},
isLoading,
isError,
} as ReturnType<typeof metricsExplorerHooks.useGetMetricMetadata>;
}

View File

@@ -1,6 +1,6 @@
export const METRIC_METADATA_KEYS = {
description: 'Description',
unit: 'Unit',
metric_type: 'Metric Type',
metricType: 'Metric Type',
temporality: 'Temporality',
};

View File

@@ -1,10 +1,13 @@
import {
MetricDetails,
MetricDetailsAlert,
MetricDetailsAttribute,
MetricDetailsDashboard,
} from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
MetricsexplorertypesMetricAlertDTO,
MetricsexplorertypesMetricAttributeDTO,
MetricsexplorertypesMetricDashboardDTO,
MetricsexplorertypesMetricHighlightsResponseDTO,
MetricsexplorertypesMetricMetadataDTO,
MetricsexplorertypesMetricMetadataDTOType,
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
export interface MetricDetailsProps {
onClose: () => void;
@@ -14,21 +17,23 @@ export interface MetricDetailsProps {
openInspectModal?: (metricName: string) => void;
}
export interface HighlightsProps {
metricName: string;
}
export interface DashboardsAndAlertsPopoverProps {
dashboards: MetricDetailsDashboard[] | null;
alerts: MetricDetailsAlert[] | null;
metricName: string;
}
export interface MetadataProps {
metricName: string;
metadata: MetricDetails['metadata'] | undefined;
refetchMetricDetails: () => void;
metadata: MetricMetadata | null;
isErrorMetricMetadata: boolean;
isLoadingMetricMetadata: boolean;
}
export interface AllAttributesProps {
attributes: MetricDetailsAttribute[];
metricName: string;
metricType: MetricType | undefined;
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined;
}
export interface AllAttributesValueProps {
@@ -36,3 +41,27 @@ export interface AllAttributesValueProps {
filterValue: string[];
goToMetricsExploreWithAppliedAttribute: (key: string, value: string) => void;
}
export type MetricHighlight = MetricsexplorertypesMetricHighlightsResponseDTO;
export type MetricAlert = MetricsexplorertypesMetricAlertDTO;
export type MetricDashboard = MetricsexplorertypesMetricDashboardDTO;
export type MetricMetadata = MetricsexplorertypesMetricMetadataDTO;
export interface MetricMetadataState {
type: MetricsexplorertypesUpdateMetricMetadataRequestDTOType;
description: string;
temporality?: MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality;
unit: string;
}
export type MetricAttribute = MetricsexplorertypesMetricAttributeDTO;
export enum TableFields {
DESCRIPTION = 'description',
UNIT = 'unit',
TYPE = 'type',
Temporality = 'temporality',
IS_MONOTONIC = 'isMonotonic',
}

View File

@@ -1,12 +1,25 @@
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataMutationBody } from 'api/generated/services/metrics';
import {
GetMetricMetadata200,
MetricsexplorertypesMetricMetadataDTOTemporality,
MetricsexplorertypesMetricMetadataDTOType,
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
} from 'api/generated/services/sigNoz.schemas';
import { SpaceAggregation, TimeAggregation } from 'api/v5/v5';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
export function formatTimestampToReadableDate(timestamp: string): string {
import { MetricMetadata, MetricMetadataState } from './types';
export function formatTimestampToReadableDate(
timestamp: number | string | undefined,
): string {
if (!timestamp) {
return '-';
}
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
@@ -39,7 +52,10 @@ export function formatTimestampToReadableDate(timestamp: string): string {
return date.toLocaleDateString();
}
export function formatNumberToCompactFormat(num: number): string {
export function formatNumberToCompactFormat(num: number | undefined): string {
if (!num) {
return '-';
}
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
@@ -47,27 +63,37 @@ export function formatNumberToCompactFormat(num: number): string {
}
export function determineIsMonotonic(
metricType: MetricType,
temporality?: Temporality,
metricType: MetricsexplorertypesUpdateMetricMetadataRequestDTOType,
temporality?: MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality,
): boolean {
if (
metricType === MetricType.HISTOGRAM ||
metricType === MetricType.EXPONENTIAL_HISTOGRAM
metricType ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOType.histogram ||
metricType ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOType.exponentialhistogram
) {
return true;
}
if (metricType === MetricType.GAUGE || metricType === MetricType.SUMMARY) {
if (
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.gauge ||
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.summary
) {
return false;
}
if (metricType === MetricType.SUM) {
return temporality === Temporality.CUMULATIVE;
if (
metricType === MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum
) {
return (
temporality ===
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.cumulative
);
}
return false;
}
export function getMetricDetailsQuery(
metricName: string,
metricType: MetricType | undefined,
metricType: MetricsexplorertypesMetricMetadataDTOType | undefined,
filter?: { key: string; value: string },
groupBy?: string,
): Query {
@@ -75,23 +101,23 @@ export function getMetricDetailsQuery(
let spaceAggregation;
let aggregateOperator;
switch (metricType) {
case MetricType.SUM:
case MetricsexplorertypesMetricMetadataDTOType.sum:
timeAggregation = 'rate';
spaceAggregation = 'sum';
aggregateOperator = 'rate';
break;
case MetricType.GAUGE:
case MetricsexplorertypesMetricMetadataDTOType.gauge:
timeAggregation = 'avg';
spaceAggregation = 'avg';
aggregateOperator = 'avg';
break;
case MetricType.SUMMARY:
case MetricsexplorertypesMetricMetadataDTOType.summary:
timeAggregation = 'noop';
spaceAggregation = 'sum';
aggregateOperator = 'noop';
break;
case MetricType.HISTOGRAM:
case MetricType.EXPONENTIAL_HISTOGRAM:
case MetricsexplorertypesMetricMetadataDTOType.histogram:
case MetricsexplorertypesMetricMetadataDTOType.exponentialhistogram:
timeAggregation = 'noop';
spaceAggregation = 'p90';
aggregateOperator = 'noop';
@@ -160,3 +186,69 @@ export function getMetricDetailsQuery(
},
};
}
export function transformMetricMetadata(
apiData: GetMetricMetadata200 | undefined,
): MetricMetadata | null {
if (!apiData || !apiData.data) {
return null;
}
const { type, description, unit, temporality, isMonotonic } = apiData.data;
return {
type,
description,
unit,
temporality,
isMonotonic,
};
}
export function transformUpdateMetricMetadataRequest(
metricName: string,
metricMetadata: MetricMetadataState,
): UpdateMetricMetadataMutationBody {
return {
metricName: metricName,
type: metricMetadata.type,
description: metricMetadata.description,
unit: metricMetadata.unit,
temporality:
metricMetadata.temporality ??
MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified,
isMonotonic: determineIsMonotonic(
metricMetadata.type,
metricMetadata.temporality,
),
};
}
export function transformMetricType(
type: MetricsexplorertypesMetricMetadataDTOType,
): MetricsexplorertypesUpdateMetricMetadataRequestDTOType {
switch (type) {
case MetricsexplorertypesMetricMetadataDTOType.sum:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.sum;
case MetricsexplorertypesMetricMetadataDTOType.gauge:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.gauge;
case MetricsexplorertypesMetricMetadataDTOType.histogram:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.histogram;
case MetricsexplorertypesMetricMetadataDTOType.summary:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.summary;
case MetricsexplorertypesMetricMetadataDTOType.exponentialhistogram:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOType.exponentialhistogram;
}
}
export function transformTemporality(
temporality: MetricsexplorertypesMetricMetadataDTOTemporality,
): MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality {
switch (temporality) {
case MetricsexplorertypesMetricMetadataDTOTemporality.delta:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.delta;
case MetricsexplorertypesMetricMetadataDTOTemporality.cumulative:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.cumulative;
case MetricsexplorertypesMetricMetadataDTOTemporality.unspecified:
return MetricsexplorertypesUpdateMetricMetadataRequestDTOTemporality.unspecified;
}
}

View File

@@ -34,6 +34,7 @@ import {
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import './Summary.styles.scss';
@@ -128,7 +129,7 @@ function Summary(): JSX.Element {
} = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery && !isInspectModalOpen,
queryKey: [
'metricsList',
REACT_QUERY_KEY.GET_METRICS_LIST,
queryFiltersWithoutId,
orderBy,
pageSize,

View File

@@ -157,9 +157,12 @@ function ValidateRowValueWrapper({
}
export const formatNumberIntoHumanReadableFormat = (
num: number,
num: number | undefined,
addPlusSign = true,
): string => {
if (!num) {
return '-';
}
function format(num: number, divisor: number, suffix: string): string {
const value = num / divisor;
return value % 1 === 0

View File

@@ -1,5 +1,5 @@
import { useQueries, UseQueryOptions, UseQueryResult } from 'react-query';
import { getMetricMetadata } from 'api/metricsExplorer/v2/getMetricMetadata';
import { getMetricMetadata } from 'api/generated/services/metrics';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { SuccessResponseV2 } from 'types/api';
import { MetricMetadataResponse } from 'types/api/metricsExplorer/v2/getMetricMetadata';

View File

@@ -0,0 +1,13 @@
export function pluralize(
count: number,
singular: string,
plural?: string,
): string {
if (count === 1) {
return `${count} ${singular}`;
}
if (plural) {
return `${count} ${plural}`;
}
return `${count} ${singular}s`;
}

View File

@@ -74,21 +74,6 @@ type Alertmanager interface {
CreateInhibitRules(ctx context.Context, orgID valuer.UUID, rules []amConfig.InhibitRule) error
DeleteAllInhibitRulesByRuleId(ctx context.Context, orgID valuer.UUID, ruleId string) error
// Planned Maintenance CRUD
GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error)
GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error)
CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error)
EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error
DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error
// Rule State History
RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error)
GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error)
GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error)
GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error)
GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error)
// Collects stats for the organization.
statsreporter.StatsCollector
}

View File

@@ -68,30 +68,20 @@ type Server struct {
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
// maintenanceExprMuter is an optional muter for expression-based maintenance scoping
maintenanceExprMuter types.Muter
// muteStageMetrics are created once and reused across SetConfig calls
muteStageMetrics *notify.Metrics
// signozRegisterer is used for metrics in the pipeline
signozRegisterer prometheus.Registerer
}
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager, maintenanceExprMuter types.Muter) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
server := &Server{
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
maintenanceExprMuter: maintenanceExprMuter,
logger: logger.With("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver"),
registry: registry,
srvConfig: srvConfig,
orgID: orgID,
stateStore: stateStore,
stopc: make(chan struct{}),
notificationManager: nfManager,
}
server.signozRegisterer = prometheus.WrapRegistererWithPrefix("signoz_", registry)
server.signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, server.signozRegisterer)
signozRegisterer := server.signozRegisterer
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
// initialize marker
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
@@ -208,11 +198,6 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
if server.maintenanceExprMuter != nil {
muteRegisterer := prometheus.WrapRegistererWithPrefix("maintenance_mute_", signozRegisterer)
server.muteStageMetrics = notify.NewMetrics(muteRegisterer, featurecontrol.NoopFlags{})
}
return server, nil
}
@@ -220,9 +205,6 @@ func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.Ge
return alertmanagertypes.NewGettableAlertsFromAlertProvider(server.alerts, server.alertmanagerConfig, server.marker.Status, func(labels model.LabelSet) {
server.inhibitor.Mutes(labels)
server.silencer.Mutes(labels)
if server.maintenanceExprMuter != nil {
server.maintenanceExprMuter.Mutes(labels)
}
}, params)
}
@@ -311,14 +293,6 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
pipelinePeer,
)
// Inject expression-based maintenance muter into the pipeline
if server.maintenanceExprMuter != nil {
ms := notify.NewMuteStage(server.maintenanceExprMuter, server.muteStageMetrics)
for name, stage := range pipeline {
pipeline[name] = notify.MultiStage{ms, stage}
}
}
timeoutFunc := func(d time.Duration) time.Duration {
if d < notify.MinTimeout {
d = notify.MinTimeout

View File

@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"net/http"
"sync"
"testing"
"time"
@@ -23,35 +22,9 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testMuter implements types.Muter for testing maintenance expression muting.
type testMuter struct {
mu sync.RWMutex
muteFunc func(model.LabelSet) bool
calls []model.LabelSet
}
func (m *testMuter) Mutes(labels model.LabelSet) bool {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, labels)
if m.muteFunc != nil {
return m.muteFunc(labels)
}
return false
}
func (m *testMuter) getCalls() []model.LabelSet {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]model.LabelSet, len(m.calls))
copy(result, m.calls)
return result
}
func TestEndToEndAlertManagerFlow(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
@@ -117,7 +90,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, nil)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
@@ -248,257 +221,3 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
}
// TestEndToEndMaintenanceMuting verifies that the maintenance expression muter
// integrates correctly with the alertmanager server pipeline:
// 1. MuteStage is injected into the notification pipeline when a muter is provided
// 2. Alerts remain visible in GetAlerts during maintenance (muting suppresses
// notifications, not alert visibility)
// 3. The muter is called during GetAlerts for status resolution
func TestEndToEndMaintenanceMuting(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-maintenance"
// Create a muter that mutes alerts with severity == "critical"
muter := &testMuter{
muteFunc: func(labels model.LabelSet) bool {
return string(labels["severity"]) == "critical"
},
}
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
// Create server WITH the maintenance muter
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, muter)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
// Put a mix of alerts: 2 critical (should be muted) and 1 warning (should not)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "disk-usage",
"severity": "critical",
"env": "prod",
"alertname": "DiskUsageHigh",
},
},
Annotations: map[string]string{"summary": "Disk usage critical"},
StartsAt: strfmt.DateTime(now.Add(-5 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "disk-usage",
"severity": "warning",
"env": "prod",
"alertname": "DiskUsageHigh",
},
},
Annotations: map[string]string{"summary": "Disk usage warning"},
StartsAt: strfmt.DateTime(now.Add(-3 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "memory-usage",
"severity": "critical",
"env": "staging",
"alertname": "MemoryUsageHigh",
},
},
Annotations: map[string]string{"summary": "Memory usage critical"},
StartsAt: strfmt.DateTime(now.Add(-2 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
t.Run("alerts_visible_during_maintenance", func(t *testing.T) {
// Maintenance muting suppresses notifications, NOT alert visibility.
// All 3 alerts should still be returned by GetAlerts.
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
require.Len(t, alerts, 3, "All alerts should be visible during maintenance")
// Verify labels are intact
severities := map[string]int{}
for _, alert := range alerts {
severities[alert.Alert.Labels["severity"]]++
}
assert.Equal(t, 2, severities["critical"])
assert.Equal(t, 1, severities["warning"])
})
t.Run("muter_called_during_get_alerts", func(t *testing.T) {
// The muter should have been called for each alert during GetAlerts.
calls := muter.getCalls()
assert.GreaterOrEqual(t, len(calls), 3, "Muter should be called for each alert")
})
t.Run("muter_correctly_identifies_targets", func(t *testing.T) {
// Verify the muter returns correct results for different label sets
assert.True(t, muter.Mutes(model.LabelSet{"severity": "critical", "env": "prod"}),
"Should mute critical alerts")
assert.False(t, muter.Mutes(model.LabelSet{"severity": "warning", "env": "prod"}),
"Should not mute warning alerts")
assert.True(t, muter.Mutes(model.LabelSet{"severity": "critical", "env": "staging"}),
"Should mute critical regardless of env")
})
}
// TestEndToEndMaintenanceCatchAll verifies that a catch-all muter (always returns true)
// mutes all alerts while keeping them visible.
func TestEndToEndMaintenanceCatchAll(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-catchall"
// Catch-all muter: mutes everything
muter := &testMuter{
muteFunc: func(labels model.LabelSet) bool {
return true
},
}
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, muter)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-1", "alertname": "Alert1", "env": "prod",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-2", "alertname": "Alert2", "env": "staging",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
assert.Len(t, alerts, 2, "All alerts should remain visible even when catch-all muter is active")
// Verify the muter was called for each alert
calls := muter.getCalls()
assert.GreaterOrEqual(t, len(calls), 2, "Muter should be called for each alert")
}
// TestEndToEndNoMuter verifies the server works correctly without a muter (nil),
// matching the existing behavior where no maintenance muting is configured.
func TestEndToEndNoMuter(t *testing.T) {
ctx := context.Background()
providerSettings := instrumentationtest.New().ToProviderSettings()
store := nfroutingstoretest.NewMockSQLRouteStore()
store.MatchExpectationsInOrder(false)
notificationManager, err := rulebasednotification.New(ctx, providerSettings, nfmanager.Config{}, store)
require.NoError(t, err)
orgID := "test-org-nomuter"
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
// Create server WITHOUT a muter (nil)
server, err := New(ctx, logger, registry, srvCfg, orgID, stateStore, notificationManager, nil)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)
err = server.SetConfig(ctx, amConfig)
require.NoError(t, err)
now := time.Now()
testAlerts := []*alertmanagertypes.PostableAlert{
{
Alert: alertmanagertypes.AlertModel{
Labels: map[string]string{
"ruleId": "rule-1", "alertname": "Alert1", "severity": "critical",
},
},
StartsAt: strfmt.DateTime(now.Add(-1 * time.Minute)),
EndsAt: strfmt.DateTime(time.Time{}),
},
}
err = server.PutAlerts(ctx, testAlerts)
require.NoError(t, err)
time.Sleep(2 * time.Second)
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
require.NoError(t, err)
params, err := alertmanagertypes.NewGettableAlertsParams(req)
require.NoError(t, err)
alerts, err := server.GetAlerts(ctx, params)
require.NoError(t, err)
assert.Len(t, alerts, 1, "Alert should be returned when no muter is configured")
assert.Equal(t, "critical", alerts[0].Alert.Labels["severity"])
}

View File

@@ -25,7 +25,7 @@ import (
func TestServerSetConfigAndStop(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, nil)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -37,7 +37,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
func TestServerTestReceiverTypeWebhook(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, nil)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -85,7 +85,7 @@ func TestServerPutAlerts(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -133,7 +133,7 @@ func TestServerTestAlert(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -238,7 +238,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, nil)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -1,531 +0,0 @@
package clickhousealertmanagerstore
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
signozHistoryDBName = "signoz_analytics"
ruleStateHistoryTableName = "distributed_rule_state_history_v2"
maxPointsInTimeSeries = 300
)
type stateHistoryStore struct {
conn clickhouse.Conn
}
func NewStateHistoryStore(conn clickhouse.Conn) alertmanagertypes.StateHistoryStore {
return &stateHistoryStore{conn: conn}
}
func (s *stateHistoryStore) WriteRuleStateHistory(ctx context.Context, entries []alertmanagertypes.RuleStateHistory) error {
if len(entries) == 0 {
return nil
}
statement, err := s.conn.PrepareBatch(ctx, fmt.Sprintf(
"INSERT INTO %s.%s (org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
signozHistoryDBName, ruleStateHistoryTableName))
if err != nil {
return err
}
defer statement.Abort()
for _, h := range entries {
if err := statement.Append(
h.OrgID,
h.RuleID, h.RuleName,
h.OverallState, h.OverallStateChanged,
h.State, h.StateChanged,
h.UnixMilli, h.Labels,
h.Fingerprint, h.Value,
); err != nil {
return err
}
}
return statement.Send()
}
func (s *stateHistoryStore) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
query := fmt.Sprintf(
"SELECT org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint",
signozHistoryDBName, ruleStateHistoryTableName, ruleID)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var results []alertmanagertypes.RuleStateHistory
for rows.Next() {
var h alertmanagertypes.RuleStateHistory
if err := rows.Scan(
&h.OrgID,
&h.RuleID, &h.RuleName,
&h.OverallState, &h.OverallStateChanged,
&h.State, &h.StateChanged,
&h.UnixMilli, &h.Labels,
&h.Fingerprint, &h.Value,
); err != nil {
return nil, err
}
results = append(results, h)
}
return results, rows.Err()
}
func (s *stateHistoryStore) GetRuleStateHistoryTimeline(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.RuleStateTimeline, error) {
var conditions []string
conditions = append(conditions, fmt.Sprintf("org_id = '%s'", orgID))
conditions = append(conditions, fmt.Sprintf("rule_id = '%s'", ruleID))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", params.Start, params.End))
if params.State.StringValue() != "" {
conditions = append(conditions, fmt.Sprintf("state = '%s'", params.State.StringValue()))
}
whereClause := strings.Join(conditions, " AND ")
// Main query — paginated results.
query := fmt.Sprintf(
"SELECT org_id, rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value FROM %s.%s WHERE %s ORDER BY unix_milli %s LIMIT %d OFFSET %d",
signozHistoryDBName, ruleStateHistoryTableName, whereClause, params.Order.StringValue(), params.Limit, params.Offset)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var items []alertmanagertypes.RuleStateHistory
for rows.Next() {
var h alertmanagertypes.RuleStateHistory
if err := rows.Scan(
&h.OrgID,
&h.RuleID, &h.RuleName,
&h.OverallState, &h.OverallStateChanged,
&h.State, &h.StateChanged,
&h.UnixMilli, &h.Labels,
&h.Fingerprint, &h.Value,
); err != nil {
return nil, err
}
items = append(items, h)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Count query.
var total uint64
countQuery := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause)
if err := s.conn.QueryRow(ctx, countQuery).Scan(&total); err != nil {
return nil, err
}
// Labels query — distinct labels for the rule.
labelsQuery := fmt.Sprintf("SELECT DISTINCT labels FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s'",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID)
labelRows, err := s.conn.Query(ctx, labelsQuery)
if err != nil {
return nil, err
}
defer labelRows.Close()
labelsMap := make(map[string][]string)
for labelRows.Next() {
var rawLabel string
if err := labelRows.Scan(&rawLabel); err != nil {
return nil, err
}
label := map[string]string{}
if err := json.Unmarshal([]byte(rawLabel), &label); err != nil {
continue
}
for k, v := range label {
labelsMap[k] = append(labelsMap[k], v)
}
}
if items == nil {
items = []alertmanagertypes.RuleStateHistory{}
}
return &alertmanagertypes.RuleStateTimeline{
Items: items,
Total: total,
Labels: labelsMap,
}, nil
}
func (s *stateHistoryStore) GetRuleStateHistoryTopContributors(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
query := fmt.Sprintf(`SELECT
fingerprint,
any(labels) as labels,
count(*) as count
FROM %s.%s
WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d
GROUP BY fingerprint
HAVING labels != '{}'
ORDER BY count DESC`,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var contributors []alertmanagertypes.RuleStateHistoryContributor
for rows.Next() {
var c alertmanagertypes.RuleStateHistoryContributor
if err := rows.Scan(&c.Fingerprint, &c.Labels, &c.Count); err != nil {
return nil, err
}
contributors = append(contributors, c)
}
if contributors == nil {
contributors = []alertmanagertypes.RuleStateHistoryContributor{}
}
return contributors, rows.Err()
}
func (s *stateHistoryStore) GetOverallStateTransitions(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) ([]alertmanagertypes.RuleStateTransition, error) {
tmpl := `WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT *
FROM matched_events
ORDER BY firing_time ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
type transition struct {
RuleID string `ch:"rule_id"`
State string `ch:"state"`
FiringTime int64 `ch:"firing_time"`
ResolutionTime int64 `ch:"resolution_time"`
}
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var transitions []transition
for rows.Next() {
var t transition
if err := rows.Scan(&t.RuleID, &t.State, &t.FiringTime, &t.ResolutionTime); err != nil {
return nil, err
}
transitions = append(transitions, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
var stateItems []alertmanagertypes.RuleStateTransition
for idx, item := range transitions {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertState{String: valuer.NewString(item.State)},
Start: item.FiringTime,
End: item.ResolutionTime,
})
if idx < len(transitions)-1 {
nextStart := transitions[idx+1].FiringTime
if nextStart > item.ResolutionTime {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: item.ResolutionTime,
End: nextStart,
})
}
}
}
// Fetch the most recent state to fill in edges.
var lastStateStr string
stateQuery := fmt.Sprintf(
"SELECT state FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.End)
if err := s.conn.QueryRow(ctx, stateQuery).Scan(&lastStateStr); err != nil {
lastStateStr = "inactive"
}
if len(transitions) == 0 {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertState{String: valuer.NewString(lastStateStr)},
Start: params.Start,
End: params.End,
})
} else {
if lastStateStr == "inactive" {
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: params.End,
})
} else {
// Find the most recent firing event.
var firingTime int64
firingQuery := fmt.Sprintf(
"SELECT unix_milli FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND overall_state_changed = true AND overall_state = 'firing' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.End)
if err := s.conn.QueryRow(ctx, firingQuery).Scan(&firingTime); err != nil {
firingTime = transitions[len(transitions)-1].ResolutionTime
}
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: firingTime,
})
stateItems = append(stateItems, alertmanagertypes.RuleStateTransition{
State: alertmanagertypes.AlertStateFiring,
Start: firingTime,
End: params.End,
})
}
}
return stateItems, nil
}
func (s *stateHistoryStore) GetTotalTriggers(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (uint64, error) {
query := fmt.Sprintf(
"SELECT count(*) FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d",
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
var total uint64
if err := s.conn.QueryRow(ctx, query).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
func (s *stateHistoryStore) GetTriggersByInterval(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.Series, error) {
step := minAllowedStepInterval(params.Start, params.End)
query := fmt.Sprintf(
"SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE org_id = '%s' AND rule_id = '%s' AND (state_changed = true) AND (state = 'firing') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC",
step, signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
return s.queryTimeSeries(ctx, query)
}
func (s *stateHistoryStore) GetAvgResolutionTime(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (float64, error) {
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End)
var avgResolutionTime float64
if err := s.conn.QueryRow(ctx, query).Scan(&avgResolutionTime); err != nil {
return 0, err
}
return avgResolutionTime, nil
}
func (s *stateHistoryStore) GetAvgResolutionTimeByInterval(
ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory,
) (*alertmanagertypes.Series, error) {
step := minAllowedStepInterval(params.Start, params.End)
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = 'firing'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = 'inactive'
AND overall_state_changed = true
AND org_id = '%s'
AND rule_id = '%s'
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT toStartOfInterval(toDateTime(firing_time / 1000), INTERVAL %d SECOND) AS ts, AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events
GROUP BY ts
ORDER BY ts ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, orgID, ruleID, params.Start, params.End, step)
return s.queryTimeSeries(ctx, query)
}
func (s *stateHistoryStore) queryTimeSeries(ctx context.Context, query string) (*alertmanagertypes.Series, error) {
rows, err := s.conn.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
series := &alertmanagertypes.Series{
Labels: map[string]string{},
}
for rows.Next() {
var value float64
var ts interface{}
if err := rows.Scan(&value, &ts); err != nil {
return nil, err
}
// The timestamp may come back as time.Time from ClickHouse.
var timestamp int64
switch v := ts.(type) {
case int64:
timestamp = v
default:
// Try time.Time
if t, ok := ts.(interface{ UnixMilli() int64 }); ok {
timestamp = t.UnixMilli()
}
}
series.Points = append(series.Points, alertmanagertypes.Point{
Timestamp: timestamp,
Value: value,
})
}
if len(series.Points) == 0 {
return nil, nil
}
return series, rows.Err()
}
func minAllowedStepInterval(start, end int64) int64 {
step := (end - start) / maxPointsInTimeSeries / 1000
if step < 60 {
return 60
}
return step - step%60
}

View File

@@ -1,165 +0,0 @@
package sqlalertmanagerstore
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type maintenance struct {
sqlstore sqlstore.SQLStore
}
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
return &maintenance{sqlstore: store}
}
func (r *maintenance) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
storables := make([]*alertmanagertypes.StorablePlannedMaintenance, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model(&storables).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
result := make([]*alertmanagertypes.GettablePlannedMaintenance, 0, len(storables))
for _, s := range storables {
result = append(result, alertmanagertypes.ConvertStorableToGettable(s))
}
return result, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
storable := new(alertmanagertypes.StorablePlannedMaintenance)
err := r.sqlstore.
BunDB().
NewSelect().
Model(storable).
Where("id = ?", id.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return alertmanagertypes.ConvertStorableToGettable(storable), nil
}
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return valuer.UUID{}, err
}
var ruleIDsStr string
if len(maintenance.RuleIDs) > 0 {
data, err := json.Marshal(maintenance.RuleIDs)
if err != nil {
return valuer.UUID{}, err
}
ruleIDsStr = string(data)
}
storable := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
TimeAuditable: types.TimeAuditable{
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
RuleIDs: ruleIDsStr,
Expression: maintenance.Expression,
OrgID: claims.OrgID,
}
_, err = r.sqlstore.
BunDB().
NewInsert().
Model(&storable).
Exec(ctx)
if err != nil {
return valuer.UUID{}, err
}
return storable.ID, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (r *maintenance) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
}
var ruleIDsStr string
if len(maintenance.RuleIDs) > 0 {
data, err := json.Marshal(maintenance.RuleIDs)
if err != nil {
return err
}
ruleIDsStr = string(data)
}
storable := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
},
TimeAuditable: types.TimeAuditable{
CreatedAt: maintenance.CreatedAt,
UpdatedAt: time.Now(),
},
UserAuditable: types.UserAuditable{
CreatedBy: maintenance.CreatedBy,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
RuleIDs: ruleIDsStr,
Expression: maintenance.Expression,
OrgID: claims.OrgID,
}
_, err = r.sqlstore.
BunDB().
NewUpdate().
Model(&storable).
Where("id = ?", storable.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
}

View File

@@ -1845,216 +1845,3 @@ func (_c *MockAlertmanager_UpdateRoutePolicyByID_Call) RunAndReturn(run func(ctx
_c.Call.Return(run)
return _c
}
// GetAllPlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
ret := _mock.Called(ctx, orgID)
if len(ret) == 0 {
panic("no return value specified for GetAllPlannedMaintenance")
}
var r0 []*alertmanagertypes.GettablePlannedMaintenance
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*alertmanagertypes.GettablePlannedMaintenance, error)); ok {
return returnFunc(ctx, orgID)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*alertmanagertypes.GettablePlannedMaintenance)
}
r1 = ret.Error(1)
return r0, r1
}
// GetPlannedMaintenanceByID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
ret := _mock.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for GetPlannedMaintenanceByID")
}
var r0 *alertmanagertypes.GettablePlannedMaintenance
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error)); ok {
return returnFunc(ctx, id)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.GettablePlannedMaintenance)
}
r1 = ret.Error(1)
return r0, r1
}
// CreatePlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
ret := _mock.Called(ctx, maintenance)
if len(ret) == 0 {
panic("no return value specified for CreatePlannedMaintenance")
}
var r0 valuer.UUID
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error)); ok {
return returnFunc(ctx, maintenance)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance) valuer.UUID); ok {
r0 = returnFunc(ctx, maintenance)
} else {
r0 = ret.Get(0).(valuer.UUID)
}
r1 = ret.Error(1)
return r0, r1
}
// EditPlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
ret := _mock.Called(ctx, maintenance, id)
if len(ret) == 0 {
panic("no return value specified for EditPlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, alertmanagertypes.GettablePlannedMaintenance, valuer.UUID) error); ok {
r0 = returnFunc(ctx, maintenance, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeletePlannedMaintenance provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
ret := _mock.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for DeletePlannedMaintenance")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, valuer.UUID) error); ok {
r0 = returnFunc(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// RecordRuleStateHistory provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
ret := _mock.Called(ctx, orgID, entries)
if len(ret) == 0 {
panic("no return value specified for RecordRuleStateHistory")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, []alertmanagertypes.RuleStateHistory) error); ok {
r0 = returnFunc(ctx, orgID, entries)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetLastSavedRuleStateHistory provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
ret := _mock.Called(ctx, ruleID)
if len(ret) == 0 {
panic("no return value specified for GetLastSavedRuleStateHistory")
}
var r0 []alertmanagertypes.RuleStateHistory
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]alertmanagertypes.RuleStateHistory, error)); ok {
return returnFunc(ctx, ruleID)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateHistory)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStateHistoryTimeline provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStateHistoryTimeline")
}
var r0 *alertmanagertypes.RuleStateTimeline
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.RuleStateTimeline)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStateHistoryTopContributors provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStateHistoryTopContributors")
}
var r0 []alertmanagertypes.RuleStateHistoryContributor
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateHistoryContributor)
}
r1 = ret.Error(1)
return r0, r1
}
// GetOverallStateTransitions provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetOverallStateTransitions")
}
var r0 []alertmanagertypes.RuleStateTransition
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).([]alertmanagertypes.RuleStateTransition)
}
r1 = ret.Error(1)
return r0, r1
}
// GetRuleStats provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
ret := _mock.Called(ctx, orgID, ruleID, params)
if len(ret) == 0 {
panic("no return value specified for GetRuleStats")
}
var r0 *alertmanagertypes.RuleStats
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error)); ok {
return returnFunc(ctx, orgID, ruleID, params)
}
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.RuleStats)
}
r1 = ret.Error(1)
return r0, r1
}

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -400,312 +399,3 @@ func (api *API) UpdateRoutePolicy(rw http.ResponseWriter, req *http.Request) {
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
schedules, err := api.alertmanager.GetAllPlannedMaintenance(ctx, claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
if q := req.URL.Query().Get("active"); q != "" {
active, _ := strconv.ParseBool(q)
filtered := make([]*alertmanagertypes.GettablePlannedMaintenance, 0)
for _, schedule := range schedules {
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
if schedule.IsActive(now) == active {
filtered = append(filtered, schedule)
}
}
schedules = filtered
}
if q := req.URL.Query().Get("recurring"); q != "" {
recurring, _ := strconv.ParseBool(q)
filtered := make([]*alertmanagertypes.GettablePlannedMaintenance, 0)
for _, schedule := range schedules {
if schedule.IsRecurring() == recurring {
filtered = append(filtered, schedule)
}
}
schedules = filtered
}
render.Success(rw, http.StatusOK, schedules)
}
func (api *API) GetDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
schedule, err := api.alertmanager.GetPlannedMaintenanceByID(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, schedule)
}
func (api *API) CreateDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close() //nolint:errcheck
var schedule alertmanagertypes.GettablePlannedMaintenance
if err := json.Unmarshal(body, &schedule); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
_, err = api.alertmanager.CreatePlannedMaintenance(ctx, schedule)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, nil)
}
func (api *API) EditDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
body, err := io.ReadAll(req.Body)
if err != nil {
render.Error(rw, err)
return
}
defer req.Body.Close() //nolint:errcheck
var schedule alertmanagertypes.GettablePlannedMaintenance
if err := json.Unmarshal(body, &schedule); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
err = api.alertmanager.EditPlannedMaintenance(ctx, schedule, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, nil)
}
func (api *API) DeleteDowntimeSchedule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
vars := mux.Vars(req)
idString, ok := vars["id"]
if !ok {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is required in path"))
return
}
id, err := valuer.NewUUID(idString)
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
err = api.alertmanager.DeletePlannedMaintenance(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (api *API) GetRuleStateHistoryTimeline(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStateHistoryTimeline(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetRuleStats(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStats(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetRuleStateHistoryTopContributors(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetRuleStateHistoryTopContributors(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}
func (api *API) GetOverallStateTransitions(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
ruleID := mux.Vars(req)["id"]
if ruleID == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "rule ID is required"))
return
}
var params alertmanagertypes.QueryRuleStateHistory
if err := json.NewDecoder(req.Body).Decode(&params); err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid request body: %v", err))
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := api.alertmanager.GetOverallStateTransitions(ctx, claims.OrgID, ruleID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,15 +2,10 @@ package alertmanager
import (
"context"
"encoding/json"
"math"
"sync"
"time"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/matcher/compat"
amtypes "github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -43,15 +38,6 @@ type Service struct {
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
// maintenanceExprMuter is an optional muter for expression-based maintenance scoping
maintenanceExprMuter amtypes.Muter
// stateHistoryStore writes rule state history to persistent storage (e.g. ClickHouse)
stateHistoryStore alertmanagertypes.StateHistoryStore
// stateTracker tracks alert state transitions for v2 state history recording
stateTracker *stateTracker
}
func New(
@@ -62,21 +48,16 @@ func New(
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceExprMuter amtypes.Muter,
stateHistoryStore alertmanagertypes.StateHistoryStore,
) *Service {
service := &Service{
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
maintenanceExprMuter: maintenanceExprMuter,
stateHistoryStore: stateHistoryStore,
stateTracker: newStateTracker(),
config: config,
stateStore: stateStore,
configStore: configStore,
orgGetter: orgGetter,
settings: settings,
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
}
return service
@@ -150,21 +131,7 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
return err
}
// Convert to typed alerts for state tracking (same conversion the server does).
now := time.Now()
typedAlerts, _ := alertmanagertypes.NewAlertsFromPostableAlerts(
alerts, time.Duration(service.config.Global.ResolveTimeout), now,
)
// Delegate to server for notification pipeline.
if err := server.PutAlerts(ctx, alerts); err != nil {
return err
}
// Record state history from the incoming alerts.
service.recordStateHistoryFromAlerts(ctx, orgID, typedAlerts, now)
return nil
return server.PutAlerts(ctx, alerts)
}
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
@@ -209,7 +176,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager, service.maintenanceExprMuter)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
if err != nil {
return nil, err
}
@@ -288,205 +255,6 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
}
// RecordRuleStateHistory applies maintenance muting logic and writes state history entries.
// For each entry with State=="firing", if the maintenance muter matches the entry's labels,
// the state is changed to "muted" before writing.
func (service *Service) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
if service.stateHistoryStore == nil {
return nil
}
for i := range entries {
entries[i].OrgID = orgID
}
if service.maintenanceExprMuter != nil {
for i := range entries {
if entries[i].State != "firing" {
continue
}
lbls := labelsFromJSON(entries[i].Labels)
if lbls == nil {
continue
}
// Add ruleId to the label set for muter matching.
lbls["ruleId"] = model.LabelValue(entries[i].RuleID)
if service.maintenanceExprMuter.Mutes(lbls) {
entries[i].State = "muted"
}
}
}
return service.stateHistoryStore.WriteRuleStateHistory(ctx, entries)
}
func (service *Service) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
if service.stateHistoryStore == nil {
return nil, nil
}
return service.stateHistoryStore.GetLastSavedRuleStateHistory(ctx, ruleID)
}
func (service *Service) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
if service.stateHistoryStore == nil {
return &alertmanagertypes.RuleStateTimeline{Items: []alertmanagertypes.RuleStateHistory{}}, nil
}
return service.stateHistoryStore.GetRuleStateHistoryTimeline(ctx, orgID, ruleID, params)
}
func (service *Service) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
if service.stateHistoryStore == nil {
return []alertmanagertypes.RuleStateHistoryContributor{}, nil
}
return service.stateHistoryStore.GetRuleStateHistoryTopContributors(ctx, orgID, ruleID, params)
}
func (service *Service) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
if service.stateHistoryStore == nil {
return []alertmanagertypes.RuleStateTransition{}, nil
}
return service.stateHistoryStore.GetOverallStateTransitions(ctx, orgID, ruleID, params)
}
func (service *Service) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
if service.stateHistoryStore == nil {
return &alertmanagertypes.RuleStats{}, nil
}
store := service.stateHistoryStore
// Current period stats.
totalCurrentTriggers, err := store.GetTotalTriggers(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentTriggersSeries, err := store.GetTriggersByInterval(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentAvgResolutionTime, err := store.GetAvgResolutionTime(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
currentAvgResolutionTimeSeries, err := store.GetAvgResolutionTimeByInterval(ctx, orgID, ruleID, params)
if err != nil {
return nil, err
}
// Past period stats — shift time window backward.
pastParams := *params
duration := params.End - params.Start
if duration >= 86400000 {
days := int64(math.Ceil(float64(duration) / 86400000))
pastParams.Start -= days * 86400000
pastParams.End -= days * 86400000
} else {
pastParams.Start -= 86400000
pastParams.End -= 86400000
}
totalPastTriggers, err := store.GetTotalTriggers(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastTriggersSeries, err := store.GetTriggersByInterval(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastAvgResolutionTime, err := store.GetAvgResolutionTime(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
pastAvgResolutionTimeSeries, err := store.GetAvgResolutionTimeByInterval(ctx, orgID, ruleID, &pastParams)
if err != nil {
return nil, err
}
if math.IsNaN(currentAvgResolutionTime) || math.IsInf(currentAvgResolutionTime, 0) {
currentAvgResolutionTime = 0
}
if math.IsNaN(pastAvgResolutionTime) || math.IsInf(pastAvgResolutionTime, 0) {
pastAvgResolutionTime = 0
}
return &alertmanagertypes.RuleStats{
TotalCurrentTriggers: totalCurrentTriggers,
TotalPastTriggers: totalPastTriggers,
CurrentTriggersSeries: currentTriggersSeries,
PastTriggersSeries: pastTriggersSeries,
CurrentAvgResolutionTime: currentAvgResolutionTime,
PastAvgResolutionTime: pastAvgResolutionTime,
CurrentAvgResolutionTimeSeries: currentAvgResolutionTimeSeries,
PastAvgResolutionTimeSeries: pastAvgResolutionTimeSeries,
}, nil
}
// recordStateHistoryFromAlerts detects state transitions from incoming alerts
// and records them via RecordRuleStateHistory (which applies maintenance muting).
func (service *Service) recordStateHistoryFromAlerts(ctx context.Context, orgID string, alerts []*amtypes.Alert, now time.Time) {
if service.stateHistoryStore == nil {
return
}
entries := service.stateTracker.processAlerts(orgID, alerts, now)
if len(entries) == 0 {
return
}
if err := service.RecordRuleStateHistory(ctx, orgID, entries); err != nil {
service.settings.Logger().ErrorContext(ctx, "failed to record state history", "error", err)
}
}
// StartStateHistorySweep starts a background goroutine that periodically checks
// for stale firing alerts and records them as resolved. Call this once after creating the service.
func (service *Service) StartStateHistorySweep(ctx context.Context) {
if service.stateHistoryStore == nil {
return
}
staleTimeout := 2 * time.Duration(service.config.Global.ResolveTimeout)
if staleTimeout == 0 {
staleTimeout = 10 * time.Minute
}
go func() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
now := time.Now()
entriesByOrg := service.stateTracker.sweepStale(staleTimeout, now)
for orgID, orgEntries := range entriesByOrg {
if err := service.RecordRuleStateHistory(ctx, orgID, orgEntries); err != nil {
service.settings.Logger().ErrorContext(ctx, "failed to record stale state history", "org_id", orgID, "error", err)
}
}
}
}
}()
}
// labelsFromJSON parses a JSON string of labels into a model.LabelSet.
func labelsFromJSON(labelsJSON string) model.LabelSet {
if labelsJSON == "" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(labelsJSON), &m); err != nil {
return nil
}
ls := make(model.LabelSet, len(m))
for k, v := range m {
ls[model.LabelName(k)] = model.LabelValue(v)
}
return ls
}
// getServer returns the server for the given orgID. It should be called with the lock held.
func (service *Service) getServer(orgID string) (*alertmanagerserver.Server, error) {
server, ok := service.servers[orgID]

View File

@@ -1,426 +0,0 @@
package alertmanager
import (
"context"
"math"
"sync"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testMuter implements amtypes.Muter for testing.
type testMuter struct {
mu sync.Mutex
muteFunc func(model.LabelSet) bool
calls []model.LabelSet
}
func (m *testMuter) Mutes(labels model.LabelSet) bool {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, labels)
if m.muteFunc != nil {
return m.muteFunc(labels)
}
return false
}
func (m *testMuter) getCalls() []model.LabelSet {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]model.LabelSet, len(m.calls))
copy(result, m.calls)
return result
}
// fakeStateHistoryStore captures calls for assertion.
type fakeStateHistoryStore struct {
written []alertmanagertypes.RuleStateHistory
lastErr error
getResult []alertmanagertypes.RuleStateHistory
getErr error
// Stats method returns
totalTriggers uint64
totalTriggersErr error
triggersSeries *alertmanagertypes.Series
triggersSeriesErr error
avgResolutionTime float64
avgResolutionTimeErr error
avgResTimeSeries *alertmanagertypes.Series
avgResTimeSeriesErr error
// Captures params passed to stats methods
statsCalls []*alertmanagertypes.QueryRuleStateHistory
}
func (w *fakeStateHistoryStore) WriteRuleStateHistory(_ context.Context, entries []alertmanagertypes.RuleStateHistory) error {
w.written = append(w.written, entries...)
return w.lastErr
}
func (w *fakeStateHistoryStore) GetLastSavedRuleStateHistory(_ context.Context, _ string) ([]alertmanagertypes.RuleStateHistory, error) {
return w.getResult, w.getErr
}
func (w *fakeStateHistoryStore) GetRuleStateHistoryTimeline(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetRuleStateHistoryTopContributors(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetOverallStateTransitions(_ context.Context, _ string, _ string, _ *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
return nil, nil
}
func (w *fakeStateHistoryStore) GetTotalTriggers(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (uint64, error) {
w.statsCalls = append(w.statsCalls, params)
return w.totalTriggers, w.totalTriggersErr
}
func (w *fakeStateHistoryStore) GetTriggersByInterval(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.Series, error) {
w.statsCalls = append(w.statsCalls, params)
return w.triggersSeries, w.triggersSeriesErr
}
func (w *fakeStateHistoryStore) GetAvgResolutionTime(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (float64, error) {
w.statsCalls = append(w.statsCalls, params)
return w.avgResolutionTime, w.avgResolutionTimeErr
}
func (w *fakeStateHistoryStore) GetAvgResolutionTimeByInterval(_ context.Context, _ string, _ string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.Series, error) {
w.statsCalls = append(w.statsCalls, params)
return w.avgResTimeSeries, w.avgResTimeSeriesErr
}
func TestLabelsFromJSON(t *testing.T) {
tests := []struct {
name string
input string
want model.LabelSet
}{
{
name: "empty string",
input: "",
want: nil,
},
{
name: "invalid json",
input: "not json",
want: nil,
},
{
name: "valid labels",
input: `{"env":"prod","severity":"critical"}`,
want: model.LabelSet{
"env": "prod",
"severity": "critical",
},
},
{
name: "empty object",
input: `{}`,
want: model.LabelSet{},
},
{
name: "single label",
input: `{"alertname":"HighCPU"}`,
want: model.LabelSet{"alertname": "HighCPU"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := labelsFromJSON(tc.input)
assert.Equal(t, tc.want, got)
})
}
}
func TestRecordRuleStateHistory(t *testing.T) {
ctx := context.Background()
t.Run("nil writer returns nil", func(t *testing.T) {
svc := &Service{stateHistoryStore: nil}
err := svc.RecordRuleStateHistory(ctx, "org-1", []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing"},
})
require.NoError(t, err)
})
t.Run("no muter writes entries unchanged", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: nil,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"prod"}`},
{RuleID: "r2", State: "normal", Labels: `{"env":"staging"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
assert.Equal(t, "firing", writer.written[0].State)
assert.Equal(t, "normal", writer.written[1].State)
})
t.Run("muter changes firing to muted when matched", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["env"] == "prod"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 1)
assert.Equal(t, "muted", writer.written[0].State)
})
t.Run("muter does not change firing when not matched", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["env"] == "prod"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"env":"staging"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 1)
assert.Equal(t, "firing", writer.written[0].State)
})
t.Run("muter only affects firing entries", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(model.LabelSet) bool { return true }, // mute everything
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "normal", Labels: `{"env":"prod"}`},
{RuleID: "r2", State: "no_data", Labels: `{"env":"prod"}`},
{RuleID: "r3", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 3)
assert.Equal(t, "normal", writer.written[0].State, "normal should not be muted")
assert.Equal(t, "no_data", writer.written[1].State, "no_data should not be muted")
assert.Equal(t, "muted", writer.written[2].State, "firing should become muted")
})
t.Run("ruleId is injected into labels for muter evaluation", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["ruleId"] == "target-rule"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "target-rule", State: "firing", Labels: `{"env":"prod"}`},
{RuleID: "other-rule", State: "firing", Labels: `{"env":"prod"}`},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
assert.Equal(t, "muted", writer.written[0].State, "target-rule should be muted")
assert.Equal(t, "firing", writer.written[1].State, "other-rule should not be muted")
// Verify the muter received labels with ruleId injected
calls := muter.getCalls()
require.Len(t, calls, 2)
assert.Equal(t, model.LabelValue("target-rule"), calls[0]["ruleId"])
assert.Equal(t, model.LabelValue("other-rule"), calls[1]["ruleId"])
})
t.Run("invalid labels JSON skips muting check", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(model.LabelSet) bool { return true },
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: "not-json"},
{RuleID: "r2", State: "firing", Labels: ""},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 2)
// Both should stay firing because labels couldn't be parsed
assert.Equal(t, "firing", writer.written[0].State)
assert.Equal(t, "firing", writer.written[1].State)
// Muter should not have been called
assert.Empty(t, muter.getCalls())
})
t.Run("mixed entries with selective muting", func(t *testing.T) {
writer := &fakeStateHistoryStore{}
muter := &testMuter{
muteFunc: func(ls model.LabelSet) bool {
return ls["severity"] == "warning"
},
}
svc := &Service{
stateHistoryStore: writer,
maintenanceExprMuter: muter,
}
entries := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Labels: `{"severity":"critical"}`, Fingerprint: 1},
{RuleID: "r2", State: "firing", Labels: `{"severity":"warning"}`, Fingerprint: 2},
{RuleID: "r3", State: "normal", Labels: `{"severity":"warning"}`, Fingerprint: 3},
{RuleID: "r4", State: "firing", Labels: `{"severity":"warning"}`, Fingerprint: 4},
}
err := svc.RecordRuleStateHistory(ctx, "org-1", entries)
require.NoError(t, err)
require.Len(t, writer.written, 4)
assert.Equal(t, "firing", writer.written[0].State, "critical firing stays firing")
assert.Equal(t, "muted", writer.written[1].State, "warning firing becomes muted")
assert.Equal(t, "normal", writer.written[2].State, "normal is never muted")
assert.Equal(t, "muted", writer.written[3].State, "warning firing becomes muted")
})
}
func TestGetLastSavedRuleStateHistory(t *testing.T) {
ctx := context.Background()
t.Run("nil writer returns nil", func(t *testing.T) {
svc := &Service{stateHistoryStore: nil}
result, err := svc.GetLastSavedRuleStateHistory(ctx, "r1")
require.NoError(t, err)
assert.Nil(t, result)
})
t.Run("delegates to writer", func(t *testing.T) {
expected := []alertmanagertypes.RuleStateHistory{
{RuleID: "r1", State: "firing", Fingerprint: 123},
}
writer := &fakeStateHistoryStore{getResult: expected}
svc := &Service{stateHistoryStore: writer}
result, err := svc.GetLastSavedRuleStateHistory(ctx, "r1")
require.NoError(t, err)
assert.Equal(t, expected, result)
})
}
func TestGetRuleStats(t *testing.T) {
ctx := context.Background()
t.Run("aggregates current and past period stats", func(t *testing.T) {
currentSeries := &alertmanagertypes.Series{Points: []alertmanagertypes.Point{{Timestamp: 1000, Value: 5}}}
currentResSeries := &alertmanagertypes.Series{Points: []alertmanagertypes.Point{{Timestamp: 1000, Value: 120}}}
store := &fakeStateHistoryStore{
totalTriggers: 10,
triggersSeries: currentSeries,
avgResolutionTime: 300.5,
avgResTimeSeries: currentResSeries,
}
svc := &Service{stateHistoryStore: store}
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 1000,
End: 90000000, // ~1 day
}
result, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
assert.Equal(t, uint64(10), result.TotalCurrentTriggers)
assert.Equal(t, uint64(10), result.TotalPastTriggers)
assert.Equal(t, currentSeries, result.CurrentTriggersSeries)
assert.Equal(t, 300.5, result.CurrentAvgResolutionTime)
})
t.Run("period shifting for duration >= 1 day", func(t *testing.T) {
store := &fakeStateHistoryStore{}
svc := &Service{stateHistoryStore: store}
// 2-day window: Start=0, End=172800000 (2 days in millis)
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 0,
End: 172800000,
}
_, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
// First 4 calls are current period, next 4 are past period.
// For 2 days: ceil(172800000/86400000) = 2, shift = 2*86400000 = 172800000
require.GreaterOrEqual(t, len(store.statsCalls), 8)
pastParams := store.statsCalls[4] // first past period call
assert.Equal(t, int64(-172800000), pastParams.Start)
assert.Equal(t, int64(0), pastParams.End)
})
t.Run("period shifting for duration < 1 day", func(t *testing.T) {
store := &fakeStateHistoryStore{}
svc := &Service{stateHistoryStore: store}
// 1-hour window
params := &alertmanagertypes.QueryRuleStateHistory{
Start: 100000000,
End: 103600000, // 3600000ms = 1 hour
}
_, err := svc.GetRuleStats(ctx, "org-1", "rule-1", params)
require.NoError(t, err)
// For < 1 day: shift by exactly 1 day (86400000ms)
require.GreaterOrEqual(t, len(store.statsCalls), 8)
pastParams := store.statsCalls[4]
assert.Equal(t, int64(100000000-86400000), pastParams.Start)
assert.Equal(t, int64(103600000-86400000), pastParams.End)
})
t.Run("NaN and Inf avg resolution times are zeroed", func(t *testing.T) {
for _, val := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
store := &fakeStateHistoryStore{
avgResolutionTime: val,
}
svc := &Service{stateHistoryStore: store}
result, err := svc.GetRuleStats(ctx, "org-1", "rule-1", &alertmanagertypes.QueryRuleStateHistory{
Start: 0, End: 100000000,
})
require.NoError(t, err)
assert.Equal(t, float64(0), result.CurrentAvgResolutionTime)
assert.Equal(t, float64(0), result.PastAvgResolutionTime)
}
})
}

View File

@@ -1,150 +0,0 @@
package signozalertmanager
import (
"log/slog"
"strings"
"sync"
"github.com/expr-lang/expr"
"github.com/prometheus/common/model"
)
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map
// structure for expr-lang evaluation. When both a leaf and a deeper nested path exist
// (e.g. "foo" and "foo.bar"), the nested structure takes precedence.
func convertLabelSetToEnv(labelSet model.LabelSet) map[string]interface{} {
env := make(map[string]interface{})
for lk, lv := range labelSet {
key := strings.TrimSpace(string(lk))
value := string(lv)
if strings.Contains(key, ".") {
parts := strings.Split(key, ".")
current := env
for i, raw := range parts {
part := strings.TrimSpace(raw)
last := i == len(parts)-1
if last {
if _, isMap := current[part].(map[string]interface{}); isMap {
break
}
current[part] = value
break
}
if nextMap, ok := current[part].(map[string]interface{}); ok {
current = nextMap
continue
}
newMap := make(map[string]interface{})
current[part] = newMap
current = newMap
}
continue
}
if _, isMap := env[key].(map[string]interface{}); isMap {
continue
}
env[key] = value
}
return env
}
// evaluateExpr compiles and runs an expr-lang expression against the given label set.
func evaluateExpr(expression string, labelSet model.LabelSet) (bool, error) {
env := convertLabelSetToEnv(labelSet)
program, err := expr.Compile(expression, expr.Env(env))
if err != nil {
return false, err
}
output, err := expr.Run(program, env)
if err != nil {
return false, err
}
if boolVal, ok := output.(bool); ok {
return boolVal, nil
}
return false, nil
}
// activeMaintenanceExpr holds an active maintenance's scoping criteria.
// Muting logic: (ruleIDs match OR ruleIDs empty) AND (expression match OR expression empty).
type activeMaintenanceExpr struct {
ruleIDs []string
expression string
}
// MaintenanceExprMuter implements types.Muter for expression-based maintenance scoping.
// It evaluates expr-lang expressions against alert labels to determine if an alert
// should be muted (suppressed) during a maintenance window.
type MaintenanceExprMuter struct {
mu sync.RWMutex
expressions []activeMaintenanceExpr
logger *slog.Logger
}
// NewMaintenanceExprMuter creates a new MaintenanceExprMuter.
func NewMaintenanceExprMuter(logger *slog.Logger) *MaintenanceExprMuter {
return &MaintenanceExprMuter{
logger: logger,
}
}
// Mutes returns true if the given label set matches any active maintenance entry.
// Each entry uses AND logic: (ruleIDs match OR empty) AND (expression match OR empty).
// Empty ruleIDs means all rules are in scope. Empty expression means all labels match.
func (m *MaintenanceExprMuter) Mutes(labels model.LabelSet) bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, ae := range m.expressions {
// Check rule scope: empty ruleIDs means all rules match.
ruleMatch := len(ae.ruleIDs) == 0
if !ruleMatch {
alertRuleID := string(labels["ruleId"])
for _, rid := range ae.ruleIDs {
if rid == alertRuleID {
ruleMatch = true
break
}
}
}
if !ruleMatch {
continue
}
// Check expression scope: empty expression means all labels match.
if ae.expression == "" {
return true
}
matched, err := evaluateExpr(ae.expression, labels)
if err != nil {
m.logger.Error("failed to evaluate maintenance expression",
"expression", ae.expression,
"error", err)
continue
}
if matched {
return true
}
}
return false
}
// SetActiveExpressions updates the list of active maintenance expressions.
func (m *MaintenanceExprMuter) SetActiveExpressions(exprs []activeMaintenanceExpr) {
m.mu.Lock()
defer m.mu.Unlock()
m.expressions = exprs
}

View File

@@ -10,14 +10,12 @@ import (
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/clickhousealertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -25,34 +23,25 @@ import (
)
type provider struct {
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
maintenanceStore alertmanagertypes.MaintenanceStore
maintenanceExprMuter *MaintenanceExprMuter
orgGetter organization.Getter
stopC chan struct{}
service *alertmanager.Service
config alertmanager.Config
settings factory.ScopedProviderSettings
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
stopC chan struct{}
}
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager, telemetryStore)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
maintenanceExprMuter := NewMaintenanceExprMuter(settings.Logger())
var stateHistoryStore alertmanagertypes.StateHistoryStore
if telemetryStore != nil {
stateHistoryStore = clickhousealertmanagerstore.NewStateHistoryStore(telemetryStore.ClickhouseDB())
}
p := &provider{
service: alertmanager.New(
@@ -63,18 +52,13 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
configStore,
orgGetter,
notificationManager,
maintenanceExprMuter,
stateHistoryStore,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
maintenanceStore: sqlalertmanagerstore.NewMaintenanceStore(sqlstore),
maintenanceExprMuter: maintenanceExprMuter,
orgGetter: orgGetter,
stopC: make(chan struct{}),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
stopC: make(chan struct{}),
}
return p, nil
@@ -86,28 +70,16 @@ func (provider *provider) Start(ctx context.Context) error {
return err
}
// Initial maintenance sync before entering the ticker loop.
provider.syncMaintenance(ctx, provider.maintenanceExprMuter)
// Start background sweep for stale alerts in state history tracking.
provider.service.StartStateHistorySweep(ctx)
serverTicker := time.NewTicker(provider.config.Signoz.PollInterval)
defer serverTicker.Stop()
maintenanceTicker := time.NewTicker(maintenanceSyncInterval)
defer maintenanceTicker.Stop()
ticker := time.NewTicker(provider.config.Signoz.PollInterval)
defer ticker.Stop()
for {
select {
case <-provider.stopC:
return nil
case <-serverTicker.C:
case <-ticker.C:
if err := provider.service.SyncServers(ctx); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to sync alertmanager servers", "error", err)
}
case <-maintenanceTicker.C:
provider.syncMaintenance(ctx, provider.maintenanceExprMuter)
}
}
}
@@ -589,89 +561,3 @@ func (provider *provider) DeleteAllInhibitRulesByRuleId(ctx context.Context, org
return provider.configStore.Set(ctx, config)
}
func (provider *provider) GetAllPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.GettablePlannedMaintenance, error) {
return provider.maintenanceStore.GetAllPlannedMaintenance(ctx, orgID)
}
func (provider *provider) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.GettablePlannedMaintenance, error) {
return provider.maintenanceStore.GetPlannedMaintenanceByID(ctx, id)
}
func (provider *provider) CreatePlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance) (valuer.UUID, error) {
return provider.maintenanceStore.CreatePlannedMaintenance(ctx, maintenance)
}
func (provider *provider) EditPlannedMaintenance(ctx context.Context, maintenance alertmanagertypes.GettablePlannedMaintenance, id valuer.UUID) error {
return provider.maintenanceStore.EditPlannedMaintenance(ctx, maintenance, id)
}
func (provider *provider) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
return provider.maintenanceStore.DeletePlannedMaintenance(ctx, id)
}
func (provider *provider) RecordRuleStateHistory(ctx context.Context, orgID string, entries []alertmanagertypes.RuleStateHistory) error {
return provider.service.RecordRuleStateHistory(ctx, orgID, entries)
}
func (provider *provider) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]alertmanagertypes.RuleStateHistory, error) {
return provider.service.GetLastSavedRuleStateHistory(ctx, ruleID)
}
func (provider *provider) GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStateTimeline, error) {
return provider.service.GetRuleStateHistoryTimeline(ctx, orgID, ruleID, params)
}
func (provider *provider) GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateHistoryContributor, error) {
return provider.service.GetRuleStateHistoryTopContributors(ctx, orgID, ruleID, params)
}
func (provider *provider) GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) ([]alertmanagertypes.RuleStateTransition, error) {
return provider.service.GetOverallStateTransitions(ctx, orgID, ruleID, params)
}
func (provider *provider) GetRuleStats(ctx context.Context, orgID string, ruleID string, params *alertmanagertypes.QueryRuleStateHistory) (*alertmanagertypes.RuleStats, error) {
return provider.service.GetRuleStats(ctx, orgID, ruleID, params)
}
const (
maintenanceSyncInterval = 30 * time.Second
)
// syncMaintenance checks planned maintenance windows and updates the given
// MaintenanceExprMuter with active maintenance entries. The muter is injected
// into the notification pipeline as a MuteStage, suppressing notifications
// while allowing rules to continue evaluating (preserving state history).
func (provider *provider) syncMaintenance(ctx context.Context, muter *MaintenanceExprMuter) {
orgs, err := provider.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to list orgs for maintenance sync", "error", err)
return
}
now := time.Now()
var activeExprs []activeMaintenanceExpr
for _, org := range orgs {
orgID := org.ID.StringValue()
maintenanceList, err := provider.maintenanceStore.GetAllPlannedMaintenance(ctx, orgID)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to get planned maintenance for sync", "orgID", orgID, "error", err)
continue
}
for _, maint := range maintenanceList {
_, active := maint.CurrentWindowEndTime(now)
if !active {
continue
}
activeExprs = append(activeExprs, activeMaintenanceExpr{
ruleIDs: maint.RuleIDs,
expression: maint.Expression,
})
}
}
muter.SetActiveExpressions(activeExprs)
}

View File

@@ -1,216 +0,0 @@
package signozalertmanager
import (
"log/slog"
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
)
func TestMaintenanceExprMuter(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
tests := []struct {
name string
exprs []activeMaintenanceExpr
labels model.LabelSet
want bool
}{
// --- no maintenance ---
{
name: "no expressions - not muted",
exprs: nil,
labels: model.LabelSet{"env": "prod"},
want: false,
},
// --- expression only (ruleIDs empty = all rules) ---
{
name: "expression only - matching",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "expression only - non-matching",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "staging"},
want: false,
},
{
name: "expression only - matches regardless of ruleId label",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod", "ruleId": "any-rule"},
want: true,
},
// --- ruleIDs only (expression empty = all labels) ---
{
name: "ruleIDs only - matching rule",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1", "rule-2"}},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
{
name: "ruleIDs only - non-matching rule",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1", "rule-2"}},
},
labels: model.LabelSet{"ruleId": "rule-3", "env": "prod"},
want: false,
},
{
name: "ruleIDs only - no ruleId label on alert",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}},
},
labels: model.LabelSet{"env": "prod"},
want: false,
},
// --- ruleIDs AND expression ---
{
name: "ruleIDs AND expression - both match",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "severity": "critical"},
want: true,
},
{
name: "ruleIDs AND expression - rule matches, expression does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "severity": "warning"},
want: false,
},
{
name: "ruleIDs AND expression - expression matches, rule does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-999", "severity": "critical"},
want: false,
},
{
name: "ruleIDs AND expression - neither matches",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}, expression: `severity == "critical"`},
},
labels: model.LabelSet{"ruleId": "rule-999", "severity": "warning"},
want: false,
},
// --- catch-all (both empty) ---
{
name: "catch-all - empty ruleIDs and empty expression mutes everything",
exprs: []activeMaintenanceExpr{
{},
},
labels: model.LabelSet{"ruleId": "any-rule", "env": "anything"},
want: true,
},
// --- multiple expressions ---
{
name: "multiple entries - first matches",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod"`},
{expression: `env == "staging"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "multiple entries - second matches",
exprs: []activeMaintenanceExpr{
{expression: `env == "staging"`},
{expression: `env == "prod"`},
},
labels: model.LabelSet{"env": "prod"},
want: true,
},
{
name: "multiple entries - none match",
exprs: []activeMaintenanceExpr{
{expression: `env == "staging"`},
{expression: `env == "dev"`},
},
labels: model.LabelSet{"env": "prod"},
want: false,
},
{
name: "multiple entries - ruleIDs entry matches, expression entry does not",
exprs: []activeMaintenanceExpr{
{ruleIDs: []string{"rule-1"}},
{expression: `env == "staging"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
// --- complex expressions ---
{
name: "complex expression with AND",
exprs: []activeMaintenanceExpr{
{expression: `severity == "critical" && env == "prod"`},
},
labels: model.LabelSet{"severity": "critical", "env": "prod"},
want: true,
},
{
name: "complex expression with AND - partial match",
exprs: []activeMaintenanceExpr{
{expression: `severity == "critical" && env == "prod"`},
},
labels: model.LabelSet{"severity": "warning", "env": "prod"},
want: false,
},
{
name: "expression with OR logic",
exprs: []activeMaintenanceExpr{
{expression: `env == "prod" || env == "staging"`},
},
labels: model.LabelSet{"env": "staging"},
want: true,
},
{
name: "expression with nested label (dotted key)",
exprs: []activeMaintenanceExpr{
{expression: `labels.env == "prod"`},
},
labels: model.LabelSet{"labels.env": "prod"},
want: true,
},
// --- ruleId as expression (user can also match ruleId via expression) ---
{
name: "expression matching specific ruleId label",
exprs: []activeMaintenanceExpr{
{expression: `ruleId == "rule-1"`},
},
labels: model.LabelSet{"ruleId": "rule-1", "env": "prod"},
want: true,
},
{
name: "expression matching specific ruleId label - non-matching",
exprs: []activeMaintenanceExpr{
{expression: `ruleId == "rule-1"`},
},
labels: model.LabelSet{"ruleId": "rule-3", "env": "prod"},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
muter := NewMaintenanceExprMuter(logger)
muter.SetActiveExpressions(tc.exprs)
got := muter.Mutes(tc.labels)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -1,263 +0,0 @@
package alertmanager
import (
"encoding/json"
"strconv"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
)
// trackedAlert represents the last known state of a single alert series.
type trackedAlert struct {
state string // "firing" or "inactive"
labels string // JSON labels
ruleName string
value float64
lastSeen time.Time
}
// ruleOverallState tracks the overall state of a rule across all its alert series.
type ruleOverallState struct {
state string // "firing" or "inactive"
}
// stateTracker maintains per-org, per-rule, per-fingerprint alert state
// to detect state transitions when PutAlerts is called.
type stateTracker struct {
mu sync.Mutex
alerts map[string]map[string]map[uint64]*trackedAlert // orgID → ruleID → fingerprint → state
overallState map[string]map[string]*ruleOverallState // orgID → ruleID → overall state
}
func newStateTracker() *stateTracker {
return &stateTracker{
alerts: make(map[string]map[string]map[uint64]*trackedAlert),
overallState: make(map[string]map[string]*ruleOverallState),
}
}
// processAlerts detects state transitions from incoming alerts and returns
// RuleStateHistory entries for transitions only.
func (t *stateTracker) processAlerts(orgID string, alerts []*types.Alert, now time.Time) []alertmanagertypes.RuleStateHistory {
t.mu.Lock()
defer t.mu.Unlock()
if _, ok := t.alerts[orgID]; !ok {
t.alerts[orgID] = make(map[string]map[uint64]*trackedAlert)
}
if _, ok := t.overallState[orgID]; !ok {
t.overallState[orgID] = make(map[string]*ruleOverallState)
}
var entries []alertmanagertypes.RuleStateHistory
// Track which rules were affected in this batch for overall_state computation.
affectedRules := make(map[string]bool)
for _, alert := range alerts {
ruleID := string(alert.Labels[model.LabelName("ruleId")])
if ruleID == "" {
continue
}
fp := uint64(alert.Fingerprint())
ruleName := string(alert.Labels[model.LabelName("alertname")])
labelsJSON := labelsToJSON(alert.Labels)
value := valueFromAnnotations(alert.Annotations)
var newState string
if !alert.EndsAt.IsZero() && !alert.EndsAt.After(now) {
newState = "inactive"
} else {
newState = "firing"
}
if _, ok := t.alerts[orgID][ruleID]; !ok {
t.alerts[orgID][ruleID] = make(map[uint64]*trackedAlert)
}
tracked, exists := t.alerts[orgID][ruleID][fp]
if !exists {
// First time seeing this alert.
t.alerts[orgID][ruleID][fp] = &trackedAlert{
state: newState,
labels: labelsJSON,
ruleName: ruleName,
value: value,
lastSeen: now,
}
if newState == "firing" {
// New firing alert — record transition.
entries = append(entries, alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: ruleName,
State: "firing",
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: labelsJSON,
Fingerprint: fp,
Value: value,
})
affectedRules[ruleID] = true
}
// Not found + resolved: no-op (we didn't track it firing).
continue
}
// Alert exists in tracker — check for transition.
tracked.lastSeen = now
tracked.value = value
tracked.labels = labelsJSON
if tracked.state != newState {
// State transition detected.
tracked.state = newState
entries = append(entries, alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: ruleName,
State: newState,
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: labelsJSON,
Fingerprint: fp,
Value: value,
})
affectedRules[ruleID] = true
}
// Same state — no transition, nothing to record.
}
// Compute overall_state for affected rules and set on entries.
for ruleID := range affectedRules {
currentOverall := t.computeOverallState(orgID, ruleID)
prevOverall, hasPrev := t.overallState[orgID][ruleID]
overallChanged := !hasPrev || prevOverall.state != currentOverall
if !hasPrev {
t.overallState[orgID][ruleID] = &ruleOverallState{state: currentOverall}
} else {
prevOverall.state = currentOverall
}
// Set overall_state on all entries for this rule.
for i := range entries {
if entries[i].RuleID == ruleID {
entries[i].OverallState = currentOverall
entries[i].OverallStateChanged = overallChanged
}
}
}
return entries
}
// computeOverallState returns "firing" if any tracked alert for the rule is firing.
func (t *stateTracker) computeOverallState(orgID, ruleID string) string {
ruleAlerts, ok := t.alerts[orgID][ruleID]
if !ok {
return "inactive"
}
for _, a := range ruleAlerts {
if a.state == "firing" {
return "firing"
}
}
return "inactive"
}
// sweepStale finds alerts that haven't been updated within staleTimeout and
// records them as resolved. Returns transition entries grouped by orgID.
func (t *stateTracker) sweepStale(staleTimeout time.Duration, now time.Time) map[string][]alertmanagertypes.RuleStateHistory {
t.mu.Lock()
defer t.mu.Unlock()
result := make(map[string][]alertmanagertypes.RuleStateHistory)
affectedRules := make(map[string]map[string]bool) // orgID → ruleID → true
for orgID, rules := range t.alerts {
for ruleID, fingerprints := range rules {
for fp, tracked := range fingerprints {
if tracked.state != "firing" {
continue
}
if now.Sub(tracked.lastSeen) <= staleTimeout {
continue
}
// Stale firing alert — mark as resolved.
tracked.state = "inactive"
result[orgID] = append(result[orgID], alertmanagertypes.RuleStateHistory{
OrgID: orgID,
RuleID: ruleID,
RuleName: tracked.ruleName,
State: "inactive",
StateChanged: true,
UnixMilli: now.UnixMilli(),
Labels: tracked.labels,
Fingerprint: fp,
Value: tracked.value,
})
if affectedRules[orgID] == nil {
affectedRules[orgID] = make(map[string]bool)
}
affectedRules[orgID][ruleID] = true
}
}
}
// Compute overall_state for affected rules.
for orgID, rules := range affectedRules {
for ruleID := range rules {
currentOverall := t.computeOverallState(orgID, ruleID)
prevOverall, hasPrev := t.overallState[orgID][ruleID]
overallChanged := !hasPrev || prevOverall.state != currentOverall
if hasPrev {
prevOverall.state = currentOverall
}
for i := range result[orgID] {
if result[orgID][i].RuleID == ruleID {
result[orgID][i].OverallState = currentOverall
result[orgID][i].OverallStateChanged = overallChanged
}
}
}
}
return result
}
// labelsToJSON converts a model.LabelSet to a JSON string.
func labelsToJSON(ls model.LabelSet) string {
m := make(map[string]string, len(ls))
for k, v := range ls {
m[string(k)] = string(v)
}
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
// valueFromAnnotations extracts the metric value from alert annotations.
func valueFromAnnotations(annotations model.LabelSet) float64 {
valStr := string(annotations[model.LabelName("value")])
if valStr == "" {
return 0
}
v, err := strconv.ParseFloat(valStr, 64)
if err != nil {
return 0
}
return v
}

View File

@@ -1,328 +0,0 @@
package alertmanager
import (
"testing"
"time"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeAlert(ruleID, alertname string, firing bool, now time.Time, extraLabels map[string]string) *types.Alert {
labels := model.LabelSet{
"ruleId": model.LabelValue(ruleID),
"alertname": model.LabelValue(alertname),
}
for k, v := range extraLabels {
labels[model.LabelName(k)] = model.LabelValue(v)
}
alert := &types.Alert{
Alert: model.Alert{
Labels: labels,
Annotations: model.LabelSet{"value": "42.5"},
StartsAt: now.Add(-1 * time.Minute),
},
UpdatedAt: now,
}
if firing {
alert.EndsAt = now.Add(5 * time.Minute) // future = firing
} else {
alert.EndsAt = now.Add(-10 * time.Second) // past = resolved
}
return alert
}
func TestProcessAlerts_NewFiringAlert(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", alerts, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "HighCPU", entries[0].RuleName)
assert.Equal(t, "org-1", entries[0].OrgID)
assert.Equal(t, true, entries[0].StateChanged)
assert.Equal(t, 42.5, entries[0].Value)
assert.Equal(t, now.UnixMilli(), entries[0].UnixMilli)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_StillFiringNoTransition(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
// First call: new firing.
entries := tracker.processAlerts("org-1", alerts, now)
require.Len(t, entries, 1)
// Second call: still firing — no transition.
entries = tracker.processAlerts("org-1", alerts, now.Add(1*time.Minute))
assert.Empty(t, entries)
}
func TestProcessAlerts_FiringThenResolved(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// First: fire the alert.
firingAlerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", firingAlerts, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
// Second: resolve the alert.
resolvedAlerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}
entries = tracker.processAlerts("org-1", resolvedAlerts, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "inactive", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_ResolvedWithoutPriorFiring(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// A resolved alert arriving without prior tracking should produce no entry.
alerts := []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now, map[string]string{"host": "server-1"}),
}
entries := tracker.processAlerts("org-1", alerts, now)
assert.Empty(t, entries)
}
func TestProcessAlerts_ReFiring(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire.
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
// Resolve.
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
// Re-fire.
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now.Add(10*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(10*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "firing", entries[0].State)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged)
}
func TestProcessAlerts_OverallStateComputation(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire two series for the same rule.
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-2"}),
}, now)
require.Len(t, entries, 2)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, "firing", entries[1].OverallState)
// Resolve only one series — overall should still be "firing".
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(5*time.Minute), map[string]string{"host": "server-1"}),
}, now.Add(5*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, false, entries[0].OverallStateChanged) // still firing overall
// Resolve the second series — overall should transition to "inactive".
entries = tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(6*time.Minute), map[string]string{"host": "server-2"}),
}, now.Add(6*time.Minute))
require.Len(t, entries, 1)
assert.Equal(t, "inactive", entries[0].State)
assert.Equal(t, "inactive", entries[0].OverallState)
assert.Equal(t, true, entries[0].OverallStateChanged) // transitioned to inactive
}
func TestProcessAlerts_MultipleRulesIndependent(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
entries := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
makeAlert("rule-2", "HighMem", true, now, map[string]string{"host": "server-1"}),
}, now)
require.Len(t, entries, 2)
// Each rule has its own overall state.
assert.Equal(t, "rule-1", entries[0].RuleID)
assert.Equal(t, "rule-2", entries[1].RuleID)
assert.Equal(t, "firing", entries[0].OverallState)
assert.Equal(t, "firing", entries[1].OverallState)
}
func TestProcessAlerts_AlertWithoutRuleIDSkipped(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "NoRuleID"},
StartsAt: now.Add(-1 * time.Minute),
EndsAt: now.Add(5 * time.Minute),
},
UpdatedAt: now,
}
entries := tracker.processAlerts("org-1", []*types.Alert{alert}, now)
assert.Empty(t, entries)
}
func TestProcessAlerts_MultipleOrgs(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Org 1 fires.
entries1 := tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
require.Len(t, entries1, 1)
assert.Equal(t, "org-1", entries1[0].OrgID)
// Org 2 fires same rule ID — independent tracking.
entries2 := tracker.processAlerts("org-2", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
require.Len(t, entries2, 1)
assert.Equal(t, "org-2", entries2[0].OrgID)
}
func TestSweepStale_FiringAlertBecomesInactive(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire an alert.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
// Sweep with staleTimeout = 5 minutes, 10 minutes later.
result := tracker.sweepStale(5*time.Minute, now.Add(10*time.Minute))
require.Len(t, result["org-1"], 1)
assert.Equal(t, "inactive", result["org-1"][0].State)
assert.Equal(t, "rule-1", result["org-1"][0].RuleID)
assert.Equal(t, "inactive", result["org-1"][0].OverallState)
assert.Equal(t, true, result["org-1"][0].OverallStateChanged)
}
func TestSweepStale_RecentAlertNotSwept(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire an alert.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, map[string]string{"host": "server-1"}),
}, now)
// Sweep with staleTimeout = 10 minutes, only 2 minutes later.
result := tracker.sweepStale(10*time.Minute, now.Add(2*time.Minute))
assert.Empty(t, result)
}
func TestSweepStale_InactiveAlertNotSwept(t *testing.T) {
tracker := newStateTracker()
now := time.Now()
// Fire then resolve.
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", true, now, nil),
}, now)
tracker.processAlerts("org-1", []*types.Alert{
makeAlert("rule-1", "HighCPU", false, now.Add(1*time.Minute), nil),
}, now.Add(1*time.Minute))
// Sweep much later — should produce nothing since alert is already inactive.
result := tracker.sweepStale(5*time.Minute, now.Add(30*time.Minute))
assert.Empty(t, result)
}
func TestLabelsToJSON(t *testing.T) {
ls := model.LabelSet{
"alertname": "HighCPU",
"env": "prod",
}
result := labelsToJSON(ls)
// Parse back and verify.
parsed := labelsFromJSON(result)
require.NotNil(t, parsed)
assert.Equal(t, model.LabelValue("HighCPU"), parsed["alertname"])
assert.Equal(t, model.LabelValue("prod"), parsed["env"])
}
func TestValueFromAnnotations(t *testing.T) {
tests := []struct {
name string
annotations model.LabelSet
want float64
}{
{
name: "valid float",
annotations: model.LabelSet{"value": "42.5"},
want: 42.5,
},
{
name: "empty value",
annotations: model.LabelSet{},
want: 0,
},
{
name: "invalid value",
annotations: model.LabelSet{"value": "not-a-number"},
want: 0,
},
{
name: "scientific notation",
annotations: model.LabelSet{"value": "1.5E+02"},
want: 150,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := valueFromAnnotations(tc.annotations)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -26,8 +26,6 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
@@ -494,109 +492,18 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.Handle("/api/v1/channels", handler.New(am.ViewAccess(aH.AlertmanagerAPI.ListChannels), handler.OpenAPIDef{
ID: "ListChannels",
Tags: []string{"channels"},
Summary: "List notification channels",
Description: "Returns all notification channels for the organization.",
Response: make([]*alertmanagertypes.Channel, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v1/channels/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetChannelByID), handler.OpenAPIDef{
ID: "GetChannelByID",
Tags: []string{"channels"},
Summary: "Get a notification channel",
Description: "Returns a single notification channel by ID.",
Response: new(alertmanagertypes.Channel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v1/channels/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.UpdateChannelByID), handler.OpenAPIDef{
ID: "UpdateChannelByID",
Tags: []string{"channels"},
Summary: "Update a notification channel",
Description: "Updates a notification channel by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.Handle("/api/v1/channels/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.DeleteChannelByID), handler.OpenAPIDef{
ID: "DeleteChannelByID",
Tags: []string{"channels"},
Summary: "Delete a notification channel",
Description: "Deletes a notification channel by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
router.Handle("/api/v1/channels", handler.New(am.EditAccess(aH.AlertmanagerAPI.CreateChannel), handler.OpenAPIDef{
ID: "CreateChannel",
Tags: []string{"channels"},
Summary: "Create a notification channel",
Description: "Creates a new notification channel.",
Response: new(alertmanagertypes.Channel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v1/testChannel", handler.New(am.EditAccess(aH.AlertmanagerAPI.TestReceiver), handler.OpenAPIDef{
ID: "TestReceiver",
Tags: []string{"channels"},
Summary: "Test a notification channel",
Description: "Sends a test alert to a receiver configuration.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.HandleFunc("/api/v1/channels", am.ViewAccess(aH.AlertmanagerAPI.ListChannels)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/channels/{id}", am.ViewAccess(aH.AlertmanagerAPI.GetChannelByID)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/channels/{id}", am.AdminAccess(aH.AlertmanagerAPI.UpdateChannelByID)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/channels/{id}", am.AdminAccess(aH.AlertmanagerAPI.DeleteChannelByID)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/channels", am.EditAccess(aH.AlertmanagerAPI.CreateChannel)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/testChannel", am.EditAccess(aH.AlertmanagerAPI.TestReceiver)).Methods(http.MethodPost)
router.Handle("/api/v1/route_policies", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetAllRoutePolicies), handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"route_policies"},
Summary: "List route policies",
Description: "Returns all notification route policies.",
Response: make([]*alertmanagertypes.GettableRoutePolicy, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRoutePolicyByID), handler.OpenAPIDef{
ID: "GetRoutePolicyByID",
Tags: []string{"route_policies"},
Summary: "Get a route policy",
Description: "Returns a single notification route policy by ID.",
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v1/route_policies", handler.New(am.AdminAccess(aH.AlertmanagerAPI.CreateRoutePolicy), handler.OpenAPIDef{
ID: "CreateRoutePolicy",
Tags: []string{"route_policies"},
Summary: "Create a route policy",
Description: "Creates a new notification route policy.",
Request: new(alertmanagertypes.PostableRoutePolicy),
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.DeleteRoutePolicyByID), handler.OpenAPIDef{
ID: "DeleteRoutePolicyByID",
Tags: []string{"route_policies"},
Summary: "Delete a route policy",
Description: "Deletes a notification route policy by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
router.Handle("/api/v1/route_policies/{id}", handler.New(am.AdminAccess(aH.AlertmanagerAPI.UpdateRoutePolicy), handler.OpenAPIDef{
ID: "UpdateRoutePolicy",
Tags: []string{"route_policies"},
Summary: "Update a route policy",
Description: "Updates a notification route policy by ID.",
Request: new(alertmanagertypes.PostableRoutePolicy),
Response: new(alertmanagertypes.GettableRoutePolicy),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.HandleFunc("/api/v1/route_policies", am.ViewAccess(aH.AlertmanagerAPI.GetAllRoutePolicies)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies/{id}", am.ViewAccess(aH.AlertmanagerAPI.GetRoutePolicyByID)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/route_policies", am.AdminAccess(aH.AlertmanagerAPI.CreateRoutePolicy)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.DeleteRoutePolicyByID)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/route_policies/{id}", am.AdminAccess(aH.AlertmanagerAPI.UpdateRoutePolicy)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/alerts", am.ViewAccess(aH.AlertmanagerAPI.GetAlerts)).Methods(http.MethodGet)
@@ -618,103 +525,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.editDowntimeSchedule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/downtime_schedules/{id}", am.EditAccess(aH.deleteDowntimeSchedule)).Methods(http.MethodDelete)
// V2 downtime schedules (alertmanager-based)
router.Handle("/api/v2/downtime_schedules", handler.New(am.ViewAccess(aH.AlertmanagerAPI.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtime_schedules"},
Summary: "List downtime schedules",
Description: "Returns all planned maintenance schedules for the organization. Supports filtering by active and recurring query parameters.",
Response: make([]*alertmanagertypes.GettablePlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
})).Methods(http.MethodGet)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetDowntimeSchedule), handler.OpenAPIDef{
ID: "GetDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Get a downtime schedule",
Description: "Returns a single planned maintenance schedule by ID.",
Response: new(alertmanagertypes.GettablePlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodGet)
router.Handle("/api/v2/downtime_schedules", handler.New(am.EditAccess(aH.AlertmanagerAPI.CreateDowntimeSchedule), handler.OpenAPIDef{
ID: "CreateDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Create a downtime schedule",
Description: "Creates a new planned maintenance schedule.",
Request: new(alertmanagertypes.GettablePlannedMaintenance),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.EditAccess(aH.AlertmanagerAPI.EditDowntimeSchedule), handler.OpenAPIDef{
ID: "EditDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Update a downtime schedule",
Description: "Updates an existing planned maintenance schedule by ID.",
Request: new(alertmanagertypes.GettablePlannedMaintenance),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodPut)
router.Handle("/api/v2/downtime_schedules/{id}", handler.New(am.EditAccess(aH.AlertmanagerAPI.DeleteDowntimeSchedule), handler.OpenAPIDef{
ID: "DeleteDowntimeSchedule",
Tags: []string{"downtime_schedules"},
Summary: "Delete a downtime schedule",
Description: "Deletes a planned maintenance schedule by ID.",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
})).Methods(http.MethodDelete)
// V2 rule state history (alertmanager-based)
router.Handle("/api/v2/rules/{id}/history/timeline", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStateHistoryTimeline), handler.OpenAPIDef{
ID: "GetRuleStateHistoryTimeline",
Tags: []string{"rule_state_history"},
Summary: "Get rule state history timeline",
Description: "Returns paginated state history entries for a rule within a time range, with optional state filter and distinct label keys for filter UI.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: new(alertmanagertypes.RuleStateTimeline),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/stats", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStats), handler.OpenAPIDef{
ID: "GetRuleStats",
Tags: []string{"rule_state_history"},
Summary: "Get rule trigger and resolution statistics",
Description: "Returns trigger counts and average resolution times for a rule, comparing the current time period against a previous period of equal length.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: new(alertmanagertypes.RuleStats),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/top_contributors", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetRuleStateHistoryTopContributors), handler.OpenAPIDef{
ID: "GetRuleStateHistoryTopContributors",
Tags: []string{"rule_state_history"},
Summary: "Get top contributing alert series",
Description: "Returns alert series (by fingerprint) that transitioned to firing most frequently for a rule within a time range, ranked by count.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: make([]alertmanagertypes.RuleStateHistoryContributor, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.Handle("/api/v2/rules/{id}/history/overall_status", handler.New(am.ViewAccess(aH.AlertmanagerAPI.GetOverallStateTransitions), handler.OpenAPIDef{
ID: "GetOverallStateTransitions",
Tags: []string{"rule_state_history"},
Summary: "Get overall state transition timeline",
Description: "Returns a timeline of contiguous firing and inactive periods for a rule within a time range, with gap-filling between transitions.",
Request: new(alertmanagertypes.QueryRuleStateHistory),
Response: make([]alertmanagertypes.RuleStateTransition, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
})).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards", am.ViewAccess(aH.List)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/dashboards", am.EditAccess(aH.Signoz.Handlers.Dashboard.Create)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards/{id}", am.ViewAccess(aH.Get)).Methods(http.MethodGet)

View File

@@ -478,14 +478,15 @@ func (r *BaseRule) RecordRuleStateHistory(ctx context.Context, prevState, curren
}
}
if len(revisedItemsToAdd) > 0 {
if len(revisedItemsToAdd) > 0 && r.reader != nil {
zap.L().Debug("writing rule state history", zap.String("ruleid", r.ID()), zap.Any("revisedItemsToAdd", revisedItemsToAdd))
entries := make([]model.RuleStateHistory, 0, len(revisedItemsToAdd))
for _, item := range revisedItemsToAdd {
entries = append(entries, item)
}
if err := r.reader.AddRuleStateHistory(ctx, entries); err != nil {
err := r.reader.AddRuleStateHistory(ctx, entries)
if err != nil {
zap.L().Error("error while inserting rule state history", zap.Error(err), zap.Any("itemsToAdd", itemsToAdd))
}
}

View File

@@ -34,7 +34,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, nil)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -34,7 +34,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, nil)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -166,8 +166,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddAuthzIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRbacToAuthzFactory(sqlstore),
sqlmigration.NewMigratePublicDashboardsFactory(sqlstore),
sqlmigration.NewCreatePlannedMaintenanceV2Factory(sqlstore),
sqlmigration.NewCreateRuleStateHistoryV2Factory(telemetryStore),
)
}
@@ -193,9 +191,9 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
)
}
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager, telemetryStore telemetrystore.TelemetryStore) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, telemetryStore),
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
)
}

View File

@@ -58,7 +58,7 @@ func TestNewProviderFactories(t *testing.T) {
assert.NotPanics(t, func() {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
notificationManager := nfmanagertest.NewMock()
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager, nil)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
})
assert.NotPanics(t, func() {

View File

@@ -311,7 +311,7 @@ func New(
ctx,
providerSettings,
config.Alertmanager,
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, telemetrystore),
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager),
config.Alertmanager.Provider,
)
if err != nil {

View File

@@ -1,45 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type createPlannedMaintenanceV2 struct {
sqlstore sqlstore.SQLStore
}
func NewCreatePlannedMaintenanceV2Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("create_planned_maintenance_v2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return &createPlannedMaintenanceV2{sqlstore: sqlstore}, nil
})
}
func (migration *createPlannedMaintenanceV2) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *createPlannedMaintenanceV2) Up(ctx context.Context, db *bun.DB) error {
_, err := db.NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfNotExists().
Exec(ctx)
return err
}
func (migration *createPlannedMaintenanceV2) Down(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfExists().
Exec(ctx)
return err
}

View File

@@ -1,80 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type createRuleStateHistoryV2 struct {
telemetryStore telemetrystore.TelemetryStore
}
func NewCreateRuleStateHistoryV2Factory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("create_rule_state_history_v2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config Config) (SQLMigration, error) {
return &createRuleStateHistoryV2{telemetryStore: telemetryStore}, nil
})
}
func (migration *createRuleStateHistoryV2) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *createRuleStateHistoryV2) Up(ctx context.Context, db *bun.DB) error {
// Create the local MergeTree table.
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS signoz_analytics.rule_state_history_v2
(
org_id LowCardinality(String),
rule_id String,
rule_name String,
fingerprint UInt64,
labels String,
state LowCardinality(String),
state_changed Bool DEFAULT true,
value Float64,
unix_milli Int64,
overall_state LowCardinality(String),
overall_state_changed Bool
)
ENGINE = MergeTree()
PARTITION BY toDate(unix_milli / 1000)
ORDER BY (org_id, rule_id, fingerprint, unix_milli)
TTL toDate(unix_milli / 1000) + INTERVAL 90 DAY
`); err != nil {
return err
}
// Create the distributed table.
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
CREATE TABLE IF NOT EXISTS signoz_analytics.distributed_rule_state_history_v2
AS signoz_analytics.rule_state_history_v2
ENGINE = Distributed('cluster', 'signoz_analytics', 'rule_state_history_v2', cityHash64(rule_id))
`); err != nil {
return err
}
return nil
}
func (migration *createRuleStateHistoryV2) Down(ctx context.Context, db *bun.DB) error {
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
DROP TABLE IF EXISTS signoz_analytics.distributed_rule_state_history_v2
`); err != nil {
return err
}
if err := migration.telemetryStore.ClickhouseDB().Exec(ctx, `
DROP TABLE IF EXISTS signoz_analytics.rule_state_history_v2
`); err != nil {
return err
}
return nil
}

View File

@@ -1,445 +0,0 @@
package alertmanagertypes
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/expr-lang/expr"
"github.com/uptrace/bun"
)
var (
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
)
type StorablePlannedMaintenance struct {
bun.BaseModel `bun:"table:planned_maintenance_v2"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *Schedule `bun:"schedule,type:text,notnull"`
RuleIDs string `bun:"rule_ids,type:text"`
Expression string `bun:"expression,type:text"`
OrgID string `bun:"org_id,type:text"`
}
type GettablePlannedMaintenance struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule"`
RuleIDs []string `json:"ruleIds,omitempty"`
Expression string `json:"expression,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status string `json:"status"`
Kind string `json:"kind"`
}
func (m *GettablePlannedMaintenance) IsActive(now time.Time) bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
currentTime := now.In(loc)
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
return true
}
}
// recurring schedule
if m.Schedule.Recurrence != nil {
start := m.Schedule.Recurrence.StartTime
// Make sure the recurrence has started
if currentTime.Before(start.In(loc)) {
return false
}
// Check if recurrence has expired
if m.Schedule.Recurrence.EndTime != nil {
endTime := *m.Schedule.Recurrence.EndTime
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
return false
}
}
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
}
}
return false
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *GettablePlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrence's
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *GettablePlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
allowedDay, ok := RepeatOnAllMap[day]
if !ok {
continue // skip invalid days
}
// Compute the day difference: allowedDay - current weekday.
delta := int(allowedDay) - int(currentTime.Weekday())
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
// If the candidate is in the future, subtract 7 days.
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -7)
}
if currentTime.Sub(candidate) <= time.Duration(rec.Duration) {
return true
}
}
return false
}
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *GettablePlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
if refDay > lastDay {
day = lastDay
}
candidate := time.Date(year, month, day,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
// Use previous month.
candidate = candidate.AddDate(0, -1, 0)
y, m, _ := candidate.Date()
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
}
// CurrentWindowEndTime returns the end time of the current active maintenance window.
// Returns zero time and false if the maintenance is not currently active.
func (m *GettablePlannedMaintenance) CurrentWindowEndTime(now time.Time) (time.Time, bool) {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return time.Time{}, false
}
currentTime := now.In(loc)
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
return endTime, true
}
}
// recurring schedule
if m.Schedule.Recurrence != nil {
start := m.Schedule.Recurrence.StartTime
if currentTime.Before(start.In(loc)) {
return time.Time{}, false
}
if m.Schedule.Recurrence.EndTime != nil {
endTime := *m.Schedule.Recurrence.EndTime
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
return time.Time{}, false
}
}
var candidate time.Time
var active bool
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
candidate, active = m.currentDailyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeWeekly:
candidate, active = m.currentWeeklyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeMonthly:
candidate, active = m.currentMonthlyWindowEnd(currentTime, m.Schedule.Recurrence, loc)
}
if active {
return candidate, true
}
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentDailyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentWeeklyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
if len(rec.RepeatOn) == 0 {
return m.currentDailyWindowEnd(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
allowedDay, ok := RepeatOnAllMap[day]
if !ok {
continue
}
delta := int(allowedDay) - int(currentTime.Weekday())
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -7)
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) currentMonthlyWindowEnd(currentTime time.Time, rec *Recurrence, loc *time.Location) (time.Time, bool) {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
if refDay > lastDay {
day = lastDay
}
candidate := time.Date(year, month, day,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, -1, 0)
y, m, _ := candidate.Date()
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
endTime := candidate.Add(time.Duration(rec.Duration))
if currentTime.Before(endTime) || currentTime.Equal(endTime) {
return endTime, true
}
return time.Time{}, false
}
func (m *GettablePlannedMaintenance) IsUpcoming() bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
}
return false
}
func (m *GettablePlannedMaintenance) IsRecurring() bool {
return m.Schedule.Recurrence != nil
}
func (m *GettablePlannedMaintenance) Validate() error {
if m.Name == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing name in the payload")
}
if m.Schedule == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing schedule in the payload")
}
if m.Schedule.Timezone == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
_, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if m.Schedule.Recurrence != nil {
if m.Schedule.Recurrence.RepeatType == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing repeat type in the payload")
}
if m.Schedule.Recurrence.Duration == 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if m.Expression != "" {
if _, err := expr.Compile(m.Expression); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid expression: %v", err)
}
}
return nil
}
func (m GettablePlannedMaintenance) MarshalJSON() ([]byte, error) {
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
var status string
if m.IsActive(now) {
status = "active"
} else if m.IsUpcoming() {
status = "upcoming"
} else {
status = "expired"
}
var kind string
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = "fixed"
} else {
kind = "recurring"
}
return json.Marshal(struct {
Id string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule"`
RuleIDs []string `json:"ruleIds,omitempty"`
Expression string `json:"expression,omitempty"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status string `json:"status"`
Kind string `json:"kind"`
}{
Id: m.Id,
Name: m.Name,
Description: m.Description,
Schedule: m.Schedule,
RuleIDs: m.RuleIDs,
Expression: m.Expression,
CreatedAt: m.CreatedAt,
CreatedBy: m.CreatedBy,
UpdatedAt: m.UpdatedAt,
UpdatedBy: m.UpdatedBy,
Status: status,
Kind: kind,
})
}
// ConvertStorableToGettable converts a StorablePlannedMaintenance to GettablePlannedMaintenance.
func ConvertStorableToGettable(s *StorablePlannedMaintenance) *GettablePlannedMaintenance {
var ruleIDs []string
if s.RuleIDs != "" {
if err := json.Unmarshal([]byte(s.RuleIDs), &ruleIDs); err != nil {
slog.Error("failed to unmarshal rule_ids from DB", "error", err, "raw", s.RuleIDs)
}
}
return &GettablePlannedMaintenance{
Id: s.ID.StringValue(),
Name: s.Name,
Description: s.Description,
Schedule: s.Schedule,
RuleIDs: ruleIDs,
Expression: s.Expression,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
CreatedBy: s.CreatedBy,
UpdatedBy: s.UpdatedBy,
}
}
type MaintenanceStore interface {
CreatePlannedMaintenance(context.Context, GettablePlannedMaintenance) (valuer.UUID, error)
DeletePlannedMaintenance(context.Context, valuer.UUID) error
GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*GettablePlannedMaintenance, error)
EditPlannedMaintenance(context.Context, GettablePlannedMaintenance, valuer.UUID) error
GetAllPlannedMaintenance(context.Context, string) ([]*GettablePlannedMaintenance, error)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
package alertmanagertypes
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
)
type RepeatType string
const (
RepeatTypeDaily RepeatType = "daily"
RepeatTypeWeekly RepeatType = "weekly"
RepeatTypeMonthly RepeatType = "monthly"
)
type RepeatOn string
const (
RepeatOnSunday RepeatOn = "sunday"
RepeatOnMonday RepeatOn = "monday"
RepeatOnTuesday RepeatOn = "tuesday"
RepeatOnWednesday RepeatOn = "wednesday"
RepeatOnThursday RepeatOn = "thursday"
RepeatOnFriday RepeatOn = "friday"
RepeatOnSaturday RepeatOn = "saturday"
)
var RepeatOnAllMap = map[RepeatOn]time.Weekday{
RepeatOnSunday: time.Sunday,
RepeatOnMonday: time.Monday,
RepeatOnTuesday: time.Tuesday,
RepeatOnWednesday: time.Wednesday,
RepeatOnThursday: time.Thursday,
RepeatOnFriday: time.Friday,
RepeatOnSaturday: time.Saturday,
}
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case float64:
*d = Duration(time.Duration(value))
return nil
case string:
tmp, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = Duration(tmp)
return nil
default:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid duration")
}
}
type Recurrence struct {
StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration Duration `json:"duration"`
RepeatType RepeatType `json:"repeatType"`
RepeatOn []RepeatOn `json:"repeatOn"`
}
func (r *Recurrence) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, r)
}
return nil
}
func (r *Recurrence) Value() (driver.Value, error) {
return json.Marshal(r)
}

View File

@@ -1,132 +0,0 @@
package alertmanagertypes
import (
"database/sql/driver"
"encoding/json"
"time"
)
type Schedule struct {
Timezone string `json:"timezone"`
StartTime time.Time `json:"startTime,omitempty"`
EndTime time.Time `json:"endTime,omitempty"`
Recurrence *Recurrence `json:"recurrence"`
}
func (s *Schedule) Scan(src interface{}) error {
if data, ok := src.([]byte); ok {
return json.Unmarshal(data, s)
}
return nil
}
func (s *Schedule) Value() (driver.Value, error) {
return json.Marshal(s)
}
func (s Schedule) MarshalJSON() ([]byte, error) {
loc, err := time.LoadLocation(s.Timezone)
if err != nil {
return nil, err
}
var startTime, endTime time.Time
if !s.StartTime.IsZero() {
startTime = time.Date(s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour(), s.StartTime.Minute(), s.StartTime.Second(), s.StartTime.Nanosecond(), loc)
}
if !s.EndTime.IsZero() {
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
}
var recurrence *Recurrence
if s.Recurrence != nil {
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
var recEndTime *time.Time
if s.Recurrence.EndTime != nil {
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
recEndTime = &end
}
recurrence = &Recurrence{
StartTime: recStartTime,
EndTime: recEndTime,
Duration: s.Recurrence.Duration,
RepeatType: s.Recurrence.RepeatType,
RepeatOn: s.Recurrence.RepeatOn,
}
}
return json.Marshal(&struct {
Timezone string `json:"timezone"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Recurrence *Recurrence `json:"recurrence,omitempty"`
}{
Timezone: s.Timezone,
StartTime: startTime.Format(time.RFC3339),
EndTime: endTime.Format(time.RFC3339),
Recurrence: recurrence,
})
}
func (s *Schedule) UnmarshalJSON(data []byte) error {
aux := &struct {
Timezone string `json:"timezone"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Recurrence *Recurrence `json:"recurrence,omitempty"`
}{}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
loc, err := time.LoadLocation(aux.Timezone)
if err != nil {
return err
}
var startTime time.Time
if aux.StartTime != "" {
startTime, err = time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
s.StartTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(), loc)
}
var endTime time.Time
if aux.EndTime != "" {
endTime, err = time.Parse(time.RFC3339, aux.EndTime)
if err != nil {
return err
}
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
}
s.Timezone = aux.Timezone
if aux.Recurrence != nil {
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
if err != nil {
return err
}
var recEndTime *time.Time
if aux.Recurrence.EndTime != nil {
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
if err != nil {
return err
}
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
recEndTime = &endConverted
}
s.Recurrence = &Recurrence{
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
EndTime: recEndTime,
Duration: aux.Recurrence.Duration,
RepeatType: aux.Recurrence.RepeatType,
RepeatOn: aux.Recurrence.RepeatOn,
}
}
return nil
}

View File

@@ -1,137 +0,0 @@
package alertmanagertypes
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
ErrCodeInvalidStateHistoryQuery = errors.MustNewCode("invalid_state_history_query")
)
// AlertState represents the state of an alert series (firing, inactive, muted, no_data)
// or the overall state of a rule (firing, inactive).
type AlertState struct {
valuer.String
}
var (
AlertStateFiring = AlertState{valuer.NewString("firing")}
AlertStateInactive = AlertState{valuer.NewString("inactive")}
AlertStateMuted = AlertState{valuer.NewString("muted")}
AlertStateNoData = AlertState{valuer.NewString("no_data")}
)
// SortOrder represents the sort direction for query results.
type SortOrder struct {
valuer.String
}
var (
SortOrderAsc = SortOrder{valuer.NewString("asc")}
SortOrderDesc = SortOrder{valuer.NewString("desc")}
)
// RuleStateHistory represents a single state transition entry stored in ClickHouse.
// Only transitions are recorded, not every evaluation.
type RuleStateHistory struct {
OrgID string `json:"orgId"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
OverallState string `json:"overallState"` // aggregate rule state: "firing" if any series fires
OverallStateChanged bool `json:"overallStateChanged"` // true if this entry changed the overall state
State string `json:"state"` // per-series state: firing, inactive, muted, no_data
StateChanged bool `json:"stateChanged"` // always true in v2 (only transitions stored)
UnixMilli int64 `json:"unixMilli"`
Labels string `json:"labels"` // JSON-encoded label set
Fingerprint uint64 `json:"fingerprint"` // hash of the full label set
Value float64 `json:"value"`
}
// QueryRuleStateHistory is the request body for all v2 state history API endpoints.
type QueryRuleStateHistory struct {
Start int64 `json:"start"` // unix millis, required
End int64 `json:"end"` // unix millis, required
State AlertState `json:"state"` // optional filter: firing, inactive, muted
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
Order SortOrder `json:"order"`
}
func (q *QueryRuleStateHistory) Validate() error {
if q.Start == 0 || q.End == 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "start and end are required")
}
if q.Offset < 0 || q.Limit < 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "offset and limit must be greater than or equal to 0")
}
if q.Order.StringValue() != SortOrderAsc.StringValue() && q.Order.StringValue() != SortOrderDesc.StringValue() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidStateHistoryQuery, "order must be asc or desc")
}
return nil
}
// RuleStateTimeline is the paginated response for the timeline endpoint.
type RuleStateTimeline struct {
Items []RuleStateHistory `json:"items"`
Total uint64 `json:"total"`
Labels map[string][]string `json:"labels"` // distinct label keys/values for filter UI
}
// RuleStateHistoryContributor is an alert series ranked by firing frequency.
type RuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint"`
Labels string `json:"labels"` // JSON-encoded label set
Count uint64 `json:"count"`
}
// RuleStateTransition represents a contiguous time period during which a rule
// was in a particular overall state (firing or inactive).
type RuleStateTransition struct {
State AlertState `json:"state"`
Start int64 `json:"start"`
End int64 `json:"end"`
}
// RuleStats compares trigger counts and avg resolution times between the current
// time period and a previous period of equal length.
type RuleStats struct {
TotalCurrentTriggers uint64 `json:"totalCurrentTriggers"`
TotalPastTriggers uint64 `json:"totalPastTriggers"`
CurrentTriggersSeries *Series `json:"currentTriggersSeries"`
PastTriggersSeries *Series `json:"pastTriggersSeries"`
CurrentAvgResolutionTime float64 `json:"currentAvgResolutionTime"`
PastAvgResolutionTime float64 `json:"pastAvgResolutionTime"`
CurrentAvgResolutionTimeSeries *Series `json:"currentAvgResolutionTimeSeries"`
PastAvgResolutionTimeSeries *Series `json:"pastAvgResolutionTimeSeries"`
}
type Series struct {
Labels map[string]string `json:"labels"`
Points []Point `json:"values"`
}
type Point struct {
Timestamp int64 `json:"timestamp"`
Value float64 `json:"value"`
}
// StateHistoryStore provides read and write access to rule state history in ClickHouse.
type StateHistoryStore interface {
WriteRuleStateHistory(ctx context.Context, entries []RuleStateHistory) error
// GetLastSavedRuleStateHistory returns the most recent transition per fingerprint,
// used to restore in-memory state after restart.
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]RuleStateHistory, error)
GetRuleStateHistoryTimeline(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*RuleStateTimeline, error)
GetRuleStateHistoryTopContributors(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) ([]RuleStateHistoryContributor, error)
// GetOverallStateTransitions returns firing/inactive periods with gap-filling.
GetOverallStateTransitions(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) ([]RuleStateTransition, error)
GetTotalTriggers(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (uint64, error)
GetTriggersByInterval(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*Series, error)
// GetAvgResolutionTime returns avg seconds between firing and next resolution.
GetAvgResolutionTime(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (float64, error)
GetAvgResolutionTimeByInterval(ctx context.Context, orgID string, ruleID string, params *QueryRuleStateHistory) (*Series, error)
}

View File

@@ -34,9 +34,9 @@ var (
// StatsRequest represents the payload accepted by the metrics stats endpoint.
type StatsRequest struct {
Filter *qbtypes.Filter `json:"filter,omitempty"`
Start int64 `json:"start"`
End int64 `json:"end"`
Limit int `json:"limit"`
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Limit int `json:"limit" required:"true"`
Offset int `json:"offset"`
OrderBy *qbtypes.OrderBy `json:"orderBy,omitempty"`
}
@@ -96,26 +96,26 @@ func (req *StatsRequest) UnmarshalJSON(data []byte) error {
// Stat represents the summary information returned per metric.
type Stat struct {
MetricName string `json:"metricName"`
Description string `json:"description"`
MetricType metrictypes.Type `json:"type"`
MetricUnit string `json:"unit"`
TimeSeries uint64 `json:"timeseries"`
Samples uint64 `json:"samples"`
MetricName string `json:"metricName" required:"true"`
Description string `json:"description" required:"true"`
MetricType metrictypes.Type `json:"type" required:"true" enum:"gauge,sum,histogram,summary,exponentialhistogram"`
MetricUnit string `json:"unit" required:"true"`
TimeSeries uint64 `json:"timeseries" required:"true"`
Samples uint64 `json:"samples" required:"true"`
}
// StatsResponse represents the aggregated metrics statistics.
type StatsResponse struct {
Metrics []Stat `json:"metrics"`
Total uint64 `json:"total"`
Metrics []Stat `json:"metrics" required:"true" nullable:"true"`
Total uint64 `json:"total" required:"true"`
}
type MetricMetadata struct {
Description string `json:"description"`
MetricType metrictypes.Type `json:"type"`
MetricUnit string `json:"unit"`
Temporality metrictypes.Temporality `json:"temporality"`
IsMonotonic bool `json:"isMonotonic"`
Description string `json:"description" required:"true"`
MetricType metrictypes.Type `json:"type" required:"true" enum:"gauge,sum,histogram,summary,exponentialhistogram"`
MetricUnit string `json:"unit" required:"true"`
Temporality metrictypes.Temporality `json:"temporality" required:"true" enum:"delta,cumulative,unspecified"`
IsMonotonic bool `json:"isMonotonic" required:"true"`
}
// MarshalBinary implements cachetypes.Cacheable interface
@@ -130,21 +130,21 @@ func (m *MetricMetadata) UnmarshalBinary(data []byte) error {
// UpdateMetricMetadataRequest represents the payload for updating metric metadata.
type UpdateMetricMetadataRequest struct {
MetricName string `json:"metricName"`
Type metrictypes.Type `json:"type"`
Description string `json:"description"`
Unit string `json:"unit"`
Temporality metrictypes.Temporality `json:"temporality"`
IsMonotonic bool `json:"isMonotonic"`
MetricName string `json:"metricName" required:"true"`
Type metrictypes.Type `json:"type" required:"true" enum:"gauge,sum,histogram,summary,exponentialhistogram"`
Description string `json:"description" required:"true"`
Unit string `json:"unit" required:"true"`
Temporality metrictypes.Temporality `json:"temporality" required:"true" enum:"delta,cumulative,unspecified"`
IsMonotonic bool `json:"isMonotonic" required:"true"`
}
// TreemapRequest represents the payload for the metrics treemap endpoint.
type TreemapRequest struct {
Filter *qbtypes.Filter `json:"filter,omitempty"`
Start int64 `json:"start"`
End int64 `json:"end"`
Limit int `json:"limit"`
Mode TreemapMode `json:"mode"`
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Limit int `json:"limit" required:"true"`
Mode TreemapMode `json:"mode" required:"true" enum:"timeseries,samples"`
}
// Validate enforces basic constraints on TreemapRequest.
@@ -210,52 +210,52 @@ func (req *TreemapRequest) UnmarshalJSON(data []byte) error {
// TreemapEntry represents each node in the treemap response.
type TreemapEntry struct {
MetricName string `json:"metricName"`
Percentage float64 `json:"percentage"`
TotalValue uint64 `json:"totalValue"`
MetricName string `json:"metricName" required:"true"`
Percentage float64 `json:"percentage" required:"true"`
TotalValue uint64 `json:"totalValue" required:"true"`
}
// TreemapResponse is the output structure for the treemap endpoint.
type TreemapResponse struct {
TimeSeries []TreemapEntry `json:"timeseries"`
Samples []TreemapEntry `json:"samples"`
TimeSeries []TreemapEntry `json:"timeseries" required:"true" nullable:"true"`
Samples []TreemapEntry `json:"samples" required:"true" nullable:"true"`
}
// MetricAlert represents an alert associated with a metric.
type MetricAlert struct {
AlertName string `json:"alertName"`
AlertID string `json:"alertId"`
AlertName string `json:"alertName" required:"true"`
AlertID string `json:"alertId" required:"true"`
}
// MetricAlertsResponse represents the response for metric alerts endpoint.
type MetricAlertsResponse struct {
Alerts []MetricAlert `json:"alerts"`
Alerts []MetricAlert `json:"alerts" required:"true" nullable:"true"`
}
// MetricDashboard represents a dashboard/widget referencing a metric.
type MetricDashboard struct {
DashboardName string `json:"dashboardName"`
DashboardID string `json:"dashboardId"`
WidgetID string `json:"widgetId"`
WidgetName string `json:"widgetName"`
DashboardName string `json:"dashboardName" required:"true"`
DashboardID string `json:"dashboardId" required:"true"`
WidgetID string `json:"widgetId" required:"true"`
WidgetName string `json:"widgetName" required:"true"`
}
// MetricDashboardsResponse represents the response for metric dashboards endpoint.
type MetricDashboardsResponse struct {
Dashboards []MetricDashboard `json:"dashboards"`
Dashboards []MetricDashboard `json:"dashboards" required:"true" nullable:"true"`
}
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
type MetricHighlightsResponse struct {
DataPoints uint64 `json:"dataPoints"`
LastReceived uint64 `json:"lastReceived"`
TotalTimeSeries uint64 `json:"totalTimeSeries"`
ActiveTimeSeries uint64 `json:"activeTimeSeries"`
DataPoints uint64 `json:"dataPoints" required:"true"`
LastReceived uint64 `json:"lastReceived" required:"true"`
TotalTimeSeries uint64 `json:"totalTimeSeries" required:"true"`
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
}
// MetricAttributesRequest represents the payload for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `json:"metricName"`
MetricName string `json:"metricName" required:"true"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
}
@@ -292,17 +292,17 @@ func (req *MetricAttributesRequest) UnmarshalJSON(data []byte) error {
// MetricAttribute represents a single attribute with its values and count.
type MetricAttribute struct {
Key string `json:"key"`
Values []string `json:"values"`
ValueCount uint64 `json:"valueCount"`
Key string `json:"key" required:"true"`
Values []string `json:"values" required:"true" nullable:"true"`
ValueCount uint64 `json:"valueCount" required:"true"`
}
// MetricAttributesResponse is the output structure for the metric attributes endpoint.
type MetricAttributesResponse struct {
Attributes []MetricAttribute `json:"attributes"`
TotalKeys int64 `json:"totalKeys"`
Attributes []MetricAttribute `json:"attributes" required:"true" nullable:"true"`
TotalKeys int64 `json:"totalKeys" required:"true"`
}
type MetricNameParams struct {
MetricName string `query:"metricName"`
MetricName string `query:"metricName" required:"true"`
}

View File

@@ -130,7 +130,7 @@ var (
SumType = Type{valuer.NewString("sum")}
HistogramType = Type{valuer.NewString("histogram")}
SummaryType = Type{valuer.NewString("summary")}
ExpHistogramType = Type{valuer.NewString("exponential_histogram")}
ExpHistogramType = Type{valuer.NewString("exponentialhistogram")}
UnspecifiedType = Type{valuer.NewString("")}
)