Compare commits

...

5 Commits

Author SHA1 Message Date
Praneeth Lingam
d94989bc42 chore(tests): using webhook channel in list rules test 2026-09-24 21:23:49 +05:30
Praneeth Lingam
89049ec4a2 chore(tests): renamed rules to ruler to match the pkg name 2026-09-24 17:46:43 +05:30
Praneeth Lingam
7761a09d8f chore(tests): split alerts suite into alertmanager and rules 2026-09-24 17:46:17 +05:30
Aditya Singh
2a7f4fd603 test(quick-filters): add settings-with-banner stories for every filters page (#12968)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- the quick filters settings panel is sized from the filters pane, not
the viewport. the case that broke was a banner shortening the layout,
which pushed the Save changes footer off screen.. so every page with
settings now has a story for exactly that.
- each new story opens settings and removes a filter first, that is what
puts the footer on screen. same play sequence as the existing dirty
story so the two are comparable side by side.
- external apis and cost meter had no settings story at all, they get
the plain and dirty ones too. cost meter's settings live on the explorer
tab so its stories start there.
- `banner` is already a global control on the app shell mocks, so no
mock changes anywhere.. the stories just turn it on.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5978

#### Additional Information

- covers logs, traces, exceptions, ai observability, external apis and
cost meter.
- the play functions have not been run here, playwright's browser is not
installed on my machine. external apis and cost meter are the ones worth
checking first since they never had a settings story.
- cc. @H4ad
2026-09-24 08:54:54 +00:00
Nikhil Mantri
ee35fc351f feat(alerts): New list API for alert rules (powers filters, sorting, pagination) (#12780)
#### Description

- New `GET /api/v3/rules` list API for alert rules: filter query DSL,
`states` filter, sort, and offset pagination (design discussion:
SigNoz/pulse-pod#324).
- Based on #12806, which extracts the shared list filter SQL compiler;
this PR adds only the rules key-policy resolver
(`sqlrulestore/filterquery_resolver.go`) on top of it.
- Rule state lives only in the rule manager's memory, so state
filtering, total, sort and pagination run in code after the SQL fetch;
total always equals what is pageable.
- Sorting is deterministic on ties: equal rows break on name then id,
always ascending, so pages never overlap or drop rows between requests.
- Response rows carry only list-page fields, deliberately excluding
`condition`, `annotations` and `notificationSettings`. The envelope also
returns the org's distinct label pairs and the reserved filter keys for
suggestions.
- Also guards previously unlocked reads of the rules map
(`ListRuleStates`, `GetRule`, `TriggeredAlerts`).

**Filter keys and operators**

| Key | Operators | Notes |
|---|---|---|
| `name`, `created_by`, `updated_by` | `=`, `!=`, `CONTAINS`, `LIKE`,
`ILIKE`, `IN` and negations | string search |
| `labels.<key>` | string operators plus `EXISTS`, `NOT EXISTS` |
missing label evaluates as empty string; keys are case-sensitive |
| `severity` | same as `labels.<key>` | alias for `labels.severity` |
| `created_at`, `updated_at` | `=`, `!=`, `<`, `<=`, `>`, `>=`,
`BETWEEN`, `NOT BETWEEN` | quoted RFC3339 values |
| `alert_type` | `=`, `!=`, `IN`, `NOT IN` | enum: `METRIC_BASED_ALERT`,
`TRACES_BASED_ALERT`, `LOGS_BASED_ALERT`, `EXCEPTIONS_BASED_ALERT` |
| `rule_type` | `=`, `!=`, `IN`, `NOT IN` | enum: `threshold_rule`,
`promql_rule`, `anomaly_rule` |

- A bare word is free text: a case-insensitive substring match over
name, description and labels.
- `state` is not a DSL key. It is the repeated `states=` query param:
`firing`, `pending`, `recovering`, `inactive`, `nodata`, `disabled`.
- An unknown key or `REGEXP` returns a 400.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#226

#### Additional Information

- A missing label evaluates as the empty string for every value
operator, one uniform rule instead of the querier's per-operator split
([`AddDefaultExistsFilter`](https://github.com/SigNoz/signoz/blob/e0da06f76d/pkg/types/querybuildertypes/querybuildertypesv5/builder_elements.go#L160));
presence is asked with `EXISTS` / `NOT EXISTS`.
- Integration tests
(`tests/integration/tests/alerts/06_list_rules_v3.py`) cover filters,
states, sorting, pagination, totals and the error contract, run against
both sqlite and postgres.
- Found while testing: the stock `create_notification_channel` fixture
teardown silently fails and leaks channels; follow-up fix needed.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-09-24 07:16:12 +00:00
104 changed files with 3286 additions and 125 deletions

View File

@@ -38,7 +38,6 @@ jobs:
fail-fast: false
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
@@ -64,6 +63,7 @@ jobs:
- querierauthz
- role
- rootuser
- ruler
- savedview
- semconvfamilies
- serviceaccount

View File

@@ -8935,6 +8935,89 @@ components:
message:
type: string
type: object
RuletypesLabelPair:
properties:
key:
type: string
value:
type: string
required:
- key
- value
type: object
RuletypesListOrder:
enum:
- asc
- desc
type: string
RuletypesListSort:
enum:
- updated_at
- created_at
- name
- state
- severity
type: string
RuletypesListableRule:
properties:
alert:
type: string
alertType:
$ref: '#/components/schemas/RuletypesAlertType'
createdAt:
format: date-time
type: string
createdBy:
type: string
description:
type: string
disabled:
type: boolean
id:
type: string
labels:
additionalProperties:
type: string
type: object
ruleType:
$ref: '#/components/schemas/RuletypesRuleType'
state:
$ref: '#/components/schemas/RuletypesAlertState'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- state
- alert
- alertType
- ruleType
type: object
RuletypesListableRules:
properties:
labels:
items:
$ref: '#/components/schemas/RuletypesLabelPair'
type: array
reservedKeywords:
items:
type: string
type: array
rules:
items:
$ref: '#/components/schemas/RuletypesListableRule'
type: array
total:
format: int64
type: integer
required:
- rules
- total
- labels
- reservedKeywords
type: object
RuletypesMatchType:
enum:
- at_least_once
@@ -20887,9 +20970,10 @@ paths:
- users
/api/v2/rules:
get:
deprecated: false
description: This endpoint lists all alert rules with their current evaluation
state
deprecated: true
description: 'This endpoint lists all alert rules with their current evaluation
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
pagination.'
operationId: ListRules
responses:
"200":
@@ -26671,6 +26755,93 @@ paths:
summary: Get metric dashboards (v2)
tags:
- metrics
/api/v3/rules:
get:
deprecated: false
description: Returns a page of alert rules with their current evaluation state,
trimmed to the fields the list page renders. Supports a filter DSL (`query`),
a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`),
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the
filter DSL, a non-reserved key is matched as a rule label directly (`team
= infra`); a key that collides with a reserved keyword matches either interpretation
(negative operators exclude both), and `labels.<key>` targets only the label.
The response also carries the org's label pairs and the reserved filter keys
for building filter suggestions.
operationId: ListRulesV3
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: states
schema:
items:
type: string
type: array
- in: query
name: sort
schema:
$ref: '#/components/schemas/RuletypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/RuletypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRules'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List alert rules (v3)
tags:
- rules
/api/v3/traces/{traceID}/flamegraph:
post:
deprecated: false

View File

@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
sql := f.TextToJsonColumn(column)
sql = append(sql, "->"...)
sql = schema.Append(f.bunf, sql, mapField)
sql = append(sql, "->>"...)
sql = schema.Append(f.bunf, sql, key)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "jsonb_typeof("...)

View File

@@ -55,6 +55,67 @@ func TestJSONExtractString(t *testing.T) {
}
}
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "PlainKey",
column: "data",
mapField: "labels",
key: "team",
expected: `"data"::jsonb->'labels'->>'team'`,
},
{
name: "DottedKey_OneMapEntry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
},
{
name: "SingleQuoteInKey_Doubled",
column: "data",
mapField: "labels",
key: "o'brien",
expected: `"data"::jsonb->'labels'->>'o''brien'`,
},
{
name: "BackslashInKey_Literal",
column: "data",
mapField: "labels",
key: `a\b`,
expected: `"data"::jsonb->'labels'->>'a\b'`,
},
{
name: "DoubleQuoteInKey_Literal",
column: "data",
mapField: "labels",
key: `a"b`,
expected: `"data"::jsonb->'labels'->>'a"b'`,
},
{
name: "QualifiedColumn",
column: "rule.data",
mapField: "labels",
key: "severity",
expected: `"rule"."data"::jsonb->'labels'->>'severity'`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string

View File

@@ -41,6 +41,8 @@ import type {
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
@@ -73,7 +75,8 @@ const withQueryKey = <T extends object, K>(
};
/**
* This endpoint lists all alert rules with their current evaluation state
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated
* @summary List alert rules
*/
export const listRules = (signal?: AbortSignal) => {
@@ -115,6 +118,7 @@ export type ListRulesQueryResult = NonNullable<
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
@@ -134,6 +138,7 @@ export function useListRules<
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
@@ -1388,3 +1393,97 @@ export const useTestRule = <
> => {
return useMutation(getTestRuleMutationOptions(options));
};
/**
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
* @summary List alert rules (v3)
*/
export const listRulesV3 = (
params?: ListRulesV3Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListRulesV3200>({
url: `/api/v3/rules`,
method: 'GET',
params,
signal,
});
};
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
};
export const getListRulesV3QueryOptions = <
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
signal,
}) => listRulesV3(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRulesV3QueryResult = NonNullable<
Awaited<ReturnType<typeof listRulesV3>>
>;
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List alert rules (v3)
*/
export function useListRulesV3<
TData = Awaited<ReturnType<typeof listRulesV3>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListRulesV3Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRulesV3>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRulesV3QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List alert rules (v3)
*/
export const invalidateListRulesV3 = async (
queryClient: QueryClient,
params?: ListRulesV3Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRulesV3QueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -10188,6 +10188,99 @@ export interface RuletypesGettableTestRuleDTO {
message?: string;
}
export interface RuletypesLabelPairDTO {
/**
* @type string
*/
key: string;
/**
* @type string
*/
value: string;
}
export enum RuletypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum RuletypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
state = 'state',
severity = 'severity',
}
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
promql_rule = 'promql_rule',
anomaly_rule = 'anomaly_rule',
}
export interface RuletypesListableRuleDTO {
/**
* @type string
*/
alert: string;
alertType: RuletypesAlertTypeDTO;
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
description?: string;
/**
* @type boolean
*/
disabled?: boolean;
/**
* @type string
*/
id: string;
/**
* @type object
*/
labels?: RuletypesListableRuleDTOLabels;
ruleType: RuletypesRuleTypeDTO;
state: RuletypesAlertStateDTO;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface RuletypesListableRulesDTO {
/**
* @type array
*/
labels: RuletypesLabelPairDTO[];
/**
* @type array
*/
reservedKeywords: string[];
/**
* @type array
*/
rules: RuletypesListableRuleDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface RuletypesRenotifyDTO {
/**
* @type array,null
@@ -10284,11 +10377,6 @@ export interface RuletypesRuleConditionDTO {
thresholds?: RuletypesRuleThresholdDataDTO;
}
export enum RuletypesRuleTypeDTO {
threshold_rule = 'threshold_rule',
promql_rule = 'promql_rule',
anomaly_rule = 'anomaly_rule',
}
export interface RuletypesPostableRuleDTO {
/**
* @type string
@@ -14189,6 +14277,45 @@ export type GetMetricDashboardsV2200 = {
status: string;
};
export type ListRulesV3Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @type array
* @description undefined
*/
states?: string[];
/**
* @description undefined
*/
sort?: RuletypesListSortDTO;
/**
* @description undefined
*/
order?: RuletypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListRulesV3200 = {
data: RuletypesListableRulesDTO;
/**
* @type string
*/
status: string;
};
export type GetFlamegraphPathParameters = {
traceID: string;
};

View File

@@ -98,17 +98,30 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
},
play: dirtyQuickFiltersSettings,
};
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -59,6 +59,35 @@ export const PortDomain: Story = {
/** The page fetches before it renders a filter, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).toBeEnabled();
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters', undefined, untilLoaded);
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/**
* The quick-filter panel has no test id of its own, and it only mounts once the
* workspace's filters have answered.
@@ -143,3 +172,24 @@ export const NoExternalCalls: Story = {
export const Loading: Story = {
args: { dataState: 'loading' },
};
/** The editable quick-filter settings panel. */
export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
};
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};

View File

@@ -146,11 +146,39 @@ export const Failed: Story = {
parameters: { allowConsoleErrors: true },
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** The editable quick-filter settings panel. */
export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: dirtyQuickFiltersSettings,
};
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** A quick-filter value selected against the LLM span query. */
export const QuickFilterSelected: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -166,18 +166,31 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
play: dirtyQuickFiltersSettings,
};
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
},
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/**

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -18,6 +19,7 @@ const pageStory = storyMocks(meterMocks, { layout: 'app' });
*/
const meta = {
title: 'Pages/Metering/Cost Meter',
tags: ['play'],
component: MeterExplorerPage,
...pageStory,
parameters: { ...pageStory.parameters },
@@ -27,6 +29,38 @@ export default meta;
type Story = StoryObj<MeterArgs>;
/** The page fetches before it renders its filters, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const openQuickFiltersSettings = async (): Promise<void> => {
// The settings control renders disabled while its permission check is in
// flight and is swapped for the enabled one once the check answers, so it is
// looked up again on every attempt; a click on the disabled one is dropped in
// silence.
const control = await waitFor(() => {
const settings = screen.getByTestId('settings-icon-container');
expect(settings).toBeEnabled();
return settings;
}, untilLoaded);
await userEvent.click(control);
await screen.findByText('Edit quick filters', undefined, untilLoaded);
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/**
* The Meter tab over the last day: what the workspace ingested in total, then
* the hourly count and size of log records, of spans, and the metric datapoints
@@ -88,3 +122,26 @@ export const ExplorerWithoutQuickFilters: Story = {
export const ViewsEmpty: Story = {
args: { tab: 'views', savedViews: 0 },
};
/** The editable quick-filter settings panel, which lives on the Explorer tab. */
export const QuickFiltersSettings: Story = {
args: { tab: 'explorer' },
play: openQuickFiltersSettings,
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
args: { tab: 'explorer' },
play: dirtyQuickFiltersSettings,
};
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { tab: 'explorer', banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};

View File

@@ -116,16 +116,29 @@ export const QuickFiltersSettings: Story = {
play: openQuickFiltersSettings,
};
const dirtyQuickFiltersSettings = async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
};
/** Settings with an unsaved filter removal and the fixed action footer. */
export const QuickFiltersSettingsDirty: Story = {
play: async (): Promise<void> => {
await openQuickFiltersSettings();
// One Remove per added filter; the first row's is the one clicked.
const [removeFilter] = await screen.findAllByRole('button', {
name: 'Remove',
});
await userEvent.click(removeFilter);
await screen.findByRole('button', { name: 'Save changes' });
},
play: dirtyQuickFiltersSettings,
};
/**
* The same panel with a banner above the shell. The banner takes 48px off the
* layout, so this is the case where the footer used to be pushed off screen:
* the panel is sized from the filters pane rather than the viewport, which
* keeps Save changes reachable.
*/
export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};

View File

@@ -15,10 +15,26 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
Description: "This endpoint lists all alert rules with their current evaluation state",
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
Response: make([]*ruletypes.Rule, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
ID: "ListRulesV3",
Tags: []string{"rules"},
Summary: "List alert rules (v3)",
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
RequestQuery: new(ruletypes.ListRulesParams),
Response: new(ruletypes.ListableRules),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err

View File

@@ -0,0 +1,20 @@
package rules
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// Compile wraps compiler errors in the rules list filter error code.
func CompileListFilter(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
compiled, errs := sqlcompiler.Compile(query, formatter, ruleFieldResolver{})
if len(errs) > 0 {
return nil, errors.NewInvalidInputf(ruletypes.ErrCodeRuleListFilterInvalid,
"invalid filter query: %s", strings.Join(errs, "; "))
}
return compiled, nil
}

View File

@@ -0,0 +1,196 @@
package rules
import (
"fmt"
"slices"
"strings"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
const (
ruleDataColumn = "rule.data"
ruleLabelsField = "labels"
nameJSONPath = "$.alert"
descriptionPath = "$.description"
labelsJSONPath = "$.labels"
alertTypePath = "$.alertType"
ruleTypePath = "$.ruleType"
)
// ruleFieldResolver maps rule list DSL keys; a non-reserved key is a case-sensitive label lookup.
type ruleFieldResolver struct{}
func (r ruleFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
key := strings.ToLower(rawKey)
// labels.<key> is the explicit way to target only the label on a reserved-key collision.
if strings.HasPrefix(key, ruletypes.DSLLabelsKeyPrefix) {
labelKey := rawKey[len(ruletypes.DSLLabelsKeyPrefix):]
if labelKey == "" {
v.AddError("labels filter is missing a key, use labels.<key>")
return ""
}
if _, allowed := ruletypes.LabelsKeyOps[operation]; !allowed {
v.AddError("operator %s is not allowed on a labels.<key> filter", sqlcompiler.OperationName(operation))
return ""
}
return r.labelComparison(v, ctx, operation, labelKey)
}
allowedOperations, isReserved := ruletypes.ReservedOps[ruletypes.DSLKey(key)]
_, labelAllowed := ruletypes.LabelsKeyOps[operation]
if !isReserved {
if !labelAllowed {
v.AddError("operator %s is not allowed on the label filter %q", sqlcompiler.OperationName(operation), rawKey)
return ""
}
return r.labelComparison(v, ctx, operation, rawKey)
}
_, reservedAllowed := allowedOperations[operation]
// reserved severity is itself the severity-label lookup; an identical spelling would duplicate the predicate
if ruletypes.DSLKey(key) == ruletypes.DSLKeySeverity && rawKey == string(ruletypes.DSLKeySeverity) {
labelAllowed = false
}
switch {
case reservedAllowed && labelAllowed:
reservedPredicate := r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key))
labelPredicate := r.labelComparison(v, ctx, operation, rawKey)
if reservedPredicate == "" || labelPredicate == "" {
return ""
}
// the key matches both the reserved field and a same-named label; a negative term must exclude both
if operation.IsNegativeOperator() {
return v.Sb.And(reservedPredicate, labelPredicate)
}
return v.Sb.Or(reservedPredicate, labelPredicate)
case reservedAllowed:
return r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key))
case labelAllowed:
return r.labelComparison(v, ctx, operation, rawKey)
default:
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
}
func (r ruleFieldResolver) resolveReservedKey(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey) string {
switch key {
case ruletypes.DSLKeyName:
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, string(key))
case ruletypes.DSLKeySeverity:
// severity is an alias for labels.severity, sharing its missing-label semantics.
return r.labelComparison(v, ctx, operation, "severity")
case ruletypes.DSLKeyCreatedBy:
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.created_by", string(key))
case ruletypes.DSLKeyUpdatedBy:
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.updated_by", string(key))
case ruletypes.DSLKeyCreatedAt:
return v.BuildTimestampComparison(ctx, operation, "rule.created_at")
case ruletypes.DSLKeyUpdatedAt:
return v.BuildTimestampComparison(ctx, operation, "rule.updated_at")
case ruletypes.DSLKeyAlertType:
return r.enumComparison(v, ctx, operation, key, alertTypePath, alertTypeValues)
case ruletypes.DSLKeyRuleType:
return r.enumComparison(v, ctx, operation, key, ruleTypePath, ruleTypeValues)
}
v.AddError("no handler for reserved key %q", key)
return ""
}
// A missing label evaluates as the empty string for every value operator; EXISTS/NOT EXISTS test the raw extraction.
func (ruleFieldResolver) labelComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, labelKey string) string {
columnExpression := string(v.Formatter.JSONExtractMapValue(ruleDataColumn, ruleLabelsField, labelKey))
switch operation {
case qbtypesv5.FilterOperatorExists:
return fmt.Sprintf("%s IS NOT NULL", columnExpression)
case qbtypesv5.FilterOperatorNotExists:
return fmt.Sprintf("%s IS NULL", columnExpression)
}
keyForError := ruletypes.DSLLabelsKeyPrefix + labelKey
columnExpression = fmt.Sprintf("COALESCE(%s, '')", columnExpression)
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, keyForError)
}
func (ruleFieldResolver) enumComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, jsonPath string, allowedValues []string) string {
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, jsonPath))
var values []string
switch operation {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual:
value, ok := v.ExtractSingleStringValue(ctx, string(key))
if !ok {
return ""
}
values = []string{value}
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
list, ok := v.ExtractStringValueList(ctx, string(key))
if !ok {
return ""
}
values = list
default:
v.AddError("operator %s on %q is not implemented", sqlcompiler.OperationName(operation), key)
return ""
}
for _, value := range values {
if !slices.Contains(allowedValues, value) {
v.AddError("invalid value %q for %q, expected one of: %s", value, key, strings.Join(allowedValues, ", "))
return ""
}
}
arguments := make([]any, len(values))
for i, s := range values {
arguments[i] = s
}
switch operation {
case qbtypesv5.FilterOperatorEqual:
return v.Sb.Equal(columnExpression, arguments[0])
case qbtypesv5.FilterOperatorNotEqual:
return v.Sb.NotEqual(columnExpression, arguments[0])
case qbtypesv5.FilterOperatorNotIn:
return v.Sb.NotIn(columnExpression, arguments...)
default:
return v.Sb.In(columnExpression, arguments...)
}
}
// ResolveFreeText searches name, description and the raw labels JSON (which also matches label keys).
func (ruleFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
nameColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
descriptionColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, descriptionPath))
labelsColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, labelsJSONPath))
return v.Sb.Or(
v.BuildFreeTextContains(v.Sb, nameColumn, value),
v.BuildFreeTextContains(v.Sb, descriptionColumn, value),
v.BuildFreeTextContains(v.Sb, labelsColumn, value),
)
}
var alertTypeValues = func() []string {
values := make([]string, 0, 4)
for _, value := range (ruletypes.AlertType("")).Enum() {
values = append(values, string(value.(ruletypes.AlertType)))
}
return values
}()
var ruleTypeValues = func() []string {
values := make([]string, 0, 3)
for _, value := range (ruletypes.RuleType{}).Enum() {
values = append(values, value.(ruletypes.RuleType).StringValue())
}
return values
}()

View File

@@ -0,0 +1,488 @@
package rules
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
emptyQueryExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := CompileListFilter(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.emptyQueryExpected {
assert.True(t, out.IsEmpty())
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// Equal instants can differ in *Location, so compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompileEmpty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "EmptyQuery_Nil", dslQueryToCompile: "", emptyQueryExpected: true},
{subtestName: "WhitespaceQuery_Nil", dslQueryToCompile: " ", emptyQueryExpected: true},
})
}
func TestCompileName(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NameEquals_MatchesReservedOrLabel",
dslQueryToCompile: "name = 'payment latency'",
expectedSQL: `(json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?)`,
expectedArgs: []any{"payment latency", "payment latency"},
},
{
subtestName: "NameContains_EscapesWildcardsBothSides",
dslQueryToCompile: "name CONTAINS '50%'",
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')`,
expectedArgs: []any{`%50\%%`, `%50\%%`},
},
{
subtestName: "NameILike",
dslQueryToCompile: "name ILIKE 'Prod%'",
expectedSQL: `(lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' OR lower(COALESCE(json_extract("rule"."data", '$.labels."name"'), '')) LIKE LOWER(?) ESCAPE '\')`,
expectedArgs: []any{"Prod%", "Prod%"},
},
{
subtestName: "NameInList",
dslQueryToCompile: "name IN ['a', 'b']",
expectedSQL: `(json_extract("rule"."data", '$.alert') IN (?, ?) OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') IN (?, ?))`,
expectedArgs: []any{"a", "b", "a", "b"},
},
{
subtestName: "NameNotEquals_ExcludesBoth",
dslQueryToCompile: "name != 'x'",
expectedSQL: `(json_extract("rule"."data", '$.alert') <> ? AND COALESCE(json_extract("rule"."data", '$.labels."name"'), '') <> ?)`,
expectedArgs: []any{"x", "x"},
},
{
subtestName: "NameExists_LabelOnly",
dslQueryToCompile: "name EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."name"') IS NOT NULL`,
},
{
subtestName: "RangeOperatorOnName_Rejected",
dslQueryToCompile: "name > 'x'",
expectedErrShouldContain: `operator > is not allowed for key "name"`,
},
{
subtestName: "RegexpOnName_Rejected",
dslQueryToCompile: "name REGEXP 'x.*'",
expectedErrShouldContain: `operator REGEXP is not allowed for key "name"`,
},
})
}
func TestCompileSeverityAndLabels(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "SeverityEquals_TargetsLabelsMap",
dslQueryToCompile: "severity = 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "SeverityNotEquals_MissingLabelAsEmptyString",
dslQueryToCompile: "severity != 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "SeverityNotEqualsEmpty_ExcludesRulesWithoutSeverity",
dslQueryToCompile: "severity != ''",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{""},
},
{
subtestName: "SeverityExists_ThroughAlias",
dslQueryToCompile: "severity EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NOT NULL`,
},
{
subtestName: "SeverityNotExists_ThroughAlias",
dslQueryToCompile: "severity NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NULL`,
},
{
subtestName: "LabelEquals",
dslQueryToCompile: "labels.team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "DottedLabelKey_OneMapEntry",
dslQueryToCompile: "labels.k8s.cluster = 'prod-1'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."k8s.cluster"'), '') = ?`,
expectedArgs: []any{"prod-1"},
},
{
subtestName: "LabelKey_CaseSensitive",
dslQueryToCompile: "labels.Team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "LabelExists",
dslQueryToCompile: "labels.team EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NOT NULL`,
},
{
subtestName: "LabelNotExists",
dslQueryToCompile: "labels.team NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NULL`,
},
{
subtestName: "LabelNotContains_IncludesLabelLessRules",
dslQueryToCompile: "labels.team NOT CONTAINS 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%infra%"},
},
{
subtestName: "LabelNotIn_IncludesLabelLessRules",
dslQueryToCompile: "labels.team NOT IN ['a', 'b']",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT IN (?, ?)`,
expectedArgs: []any{"a", "b"},
},
})
}
func TestCompileEnums(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AlertTypeEquals_MatchesEnumOrLabel",
dslQueryToCompile: "alert_type = 'LOGS_BASED_ALERT'",
expectedSQL: `(json_extract("rule"."data", '$.alertType') = ? OR COALESCE(json_extract("rule"."data", '$.labels."alert_type"'), '') = ?)`,
expectedArgs: []any{"LOGS_BASED_ALERT", "LOGS_BASED_ALERT"},
},
{
subtestName: "RuleTypeInList",
dslQueryToCompile: "rule_type IN ['threshold_rule', 'promql_rule']",
expectedSQL: `(json_extract("rule"."data", '$.ruleType') IN (?, ?) OR COALESCE(json_extract("rule"."data", '$.labels."rule_type"'), '') IN (?, ?))`,
expectedArgs: []any{"threshold_rule", "promql_rule", "threshold_rule", "promql_rule"},
},
{
subtestName: "InvalidAlertTypeValue_Rejected",
dslQueryToCompile: "alert_type = 'bogus'",
expectedErrShouldContain: `invalid value "bogus" for "alert_type"`,
},
{
subtestName: "ContainsOnRuleType_LabelOnly",
dslQueryToCompile: "rule_type CONTAINS 'thresh'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."rule_type"'), '') LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%thresh%"},
},
})
}
func TestCompileAuditColumns(t *testing.T) {
createdAt, err := time.Parse(time.RFC3339, "2026-01-02T15:04:05Z")
require.NoError(t, err)
updatedFrom, err := time.Parse(time.RFC3339, "2026-02-01T00:00:00Z")
require.NoError(t, err)
updatedTo, err := time.Parse(time.RFC3339, "2026-03-01T00:00:00Z")
require.NoError(t, err)
runCompileCases(t, []compileCase{
{
subtestName: "CreatedByEquals_MatchesColumnOrLabel",
dslQueryToCompile: "created_by = 'nikhil@signoz.io'",
expectedSQL: `(rule.created_by = ? OR COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') = ?)`,
expectedArgs: []any{"nikhil@signoz.io", "nikhil@signoz.io"},
},
{
subtestName: "CreatedAtRange",
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
expectedSQL: `rule.created_at >= ?`,
expectedArgs: []any{createdAt},
},
{
subtestName: "UpdatedAtBetween",
dslQueryToCompile: "updated_at BETWEEN '2026-02-01T00:00:00Z' AND '2026-03-01T00:00:00Z'",
expectedSQL: `rule.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{updatedFrom, updatedTo},
},
{
subtestName: "NonTimestampOnCreatedAt_Rejected",
dslQueryToCompile: "created_at >= 'yesterday'",
expectedErrShouldContain: "invalid RFC3339 timestamp",
},
})
}
func TestCompileFreeText(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "BareWord_SearchesNameDescriptionLabels",
dslQueryToCompile: "payment",
expectedSQL: `(lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\')`,
expectedArgs: []any{"%payment%", "%payment%", "%payment%"},
},
})
}
func TestCompileComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AndOfLabelAndColumn",
dslQueryToCompile: "labels.team = 'infra' AND created_by = 'x'",
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`AND (rule.created_by = ? OR COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') = ?))`,
expectedArgs: []any{"infra", "x", "x"},
},
{
subtestName: "Not_WrapsInnerPredicate",
dslQueryToCompile: "NOT (name = 'x')",
expectedSQL: `NOT ((json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?))`,
expectedArgs: []any{"x", "x"},
},
{
subtestName: "OrOfNameAndSeverity",
dslQueryToCompile: "name CONTAINS 'pay' OR severity = 'critical'",
expectedSQL: `((json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\') ` +
`OR COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?)`,
expectedArgs: []any{"%pay%", "%pay%", "critical"},
},
})
}
func TestCompileComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NameContains_LabelEquals_SeverityIn_CreatedByNotEquals",
dslQueryToCompile: `name CONTAINS 'latency' AND labels.team = 'payments' ` +
`AND severity IN ['critical', 'error'] AND created_by != 'ops@signoz.io'`,
expectedSQL: `((json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\') ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') IN (?, ?) ` +
`AND (rule.created_by <> ? AND COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') <> ?))`,
expectedArgs: []any{"%latency%", "%latency%", "payments", "critical", "error", "ops@signoz.io", "ops@signoz.io"},
},
{
subtestName: "NestedOrAnd_WithParens",
dslQueryToCompile: `(labels.env IN ['prod', 'staging'] OR name LIKE '%prod%') ` +
`AND (severity = 'critical' OR labels.team EXISTS)`,
expectedSQL: `((COALESCE(json_extract("rule"."data", '$.labels."env"'), '') IN (?, ?) ` +
`OR (json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')) ` +
`AND (COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
`OR json_extract("rule"."data", '$.labels."team"') IS NOT NULL))`,
expectedArgs: []any{"prod", "staging", "%prod%", "%prod%", "critical"},
},
{
subtestName: "NotOverGroup_AndedWithEnum",
dslQueryToCompile: `NOT (labels.team = 'infra' OR name CONTAINS 'cpu') AND alert_type = 'METRIC_BASED_ALERT'`,
expectedSQL: `(NOT ((COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`OR (json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\'))) ` +
`AND (json_extract("rule"."data", '$.alertType') = ? OR COALESCE(json_extract("rule"."data", '$.labels."alert_type"'), '') = ?))`,
expectedArgs: []any{"infra", "%cpu%", "%cpu%", "METRIC_BASED_ALERT", "METRIC_BASED_ALERT"},
},
{
subtestName: "FreeText_ThreeLevelNesting_Timestamp",
dslQueryToCompile: `prod AND (name ILIKE '%pay%' ` +
`OR (labels.team != 'infra' AND updated_at > '2026-01-02T15:04:05Z'))`,
expectedSQL: `((lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\') ` +
`AND ((lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' ` +
`OR lower(COALESCE(json_extract("rule"."data", '$.labels."name"'), '')) LIKE LOWER(?) ESCAPE '\') ` +
`OR (COALESCE(json_extract("rule"."data", '$.labels."team"'), '') <> ? AND rule.updated_at > ?)))`,
expectedArgs: []any{"%prod%", "%prod%", "%prod%", "%pay%", "%pay%", "infra",
time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
},
})
}
func TestCompileBareLabelKeys(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "BareKey_LabelMatch",
dslQueryToCompile: "team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "BareKey_CaseSensitive",
dslQueryToCompile: "Team CONTAINS 'inf'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%inf%"},
},
{
subtestName: "BareKeyExists",
dslQueryToCompile: "env EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."env"') IS NOT NULL`,
},
{
subtestName: "State_LabelLookupNotRuleState",
dslQueryToCompile: "state = 'firing'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."state"'), '') = ?`,
expectedArgs: []any{"firing"},
},
})
}
func TestCompileReservedLabelCollisions(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "UppercaseReservedKey_MatchesReservedOrExactCaseLabel",
dslQueryToCompile: "NAME = 'x'",
expectedSQL: `(json_extract("rule"."data", '$.alert') = ? OR COALESCE(json_extract("rule"."data", '$.labels."NAME"'), '') = ?)`,
expectedArgs: []any{"x", "x"},
},
{
subtestName: "SeverityExactSpelling_SinglePredicate",
dslQueryToCompile: "severity = 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "SeverityDifferentCase_MatchesBothLabelSpellings",
dslQueryToCompile: "Severity = 'critical'",
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
`OR COALESCE(json_extract("rule"."data", '$.labels."Severity"'), '') = ?)`,
expectedArgs: []any{"critical", "critical"},
},
{
subtestName: "RangeOperator_ReservedOnly",
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
expectedSQL: `rule.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
},
{
subtestName: "LabelsPrefix_LabelOnlyOnCollision",
dslQueryToCompile: "labels.name = 'x'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."name"'), '') = ?`,
expectedArgs: []any{"x"},
},
{
subtestName: "NotIn_ExcludesBoth",
dslQueryToCompile: "created_by NOT IN ['a', 'b']",
expectedSQL: `(rule.created_by NOT IN (?, ?) ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."created_by"'), '') NOT IN (?, ?))`,
expectedArgs: []any{"a", "b", "a", "b"},
},
})
}
func TestCompileErrors(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "RangeOperatorOnBareLabelKey_Rejected",
dslQueryToCompile: "team > 'infra'",
expectedErrShouldContain: `operator > is not allowed on the label filter "team"`,
},
{
subtestName: "SyntaxError_SurfacesPosition",
dslQueryToCompile: "created_by ==== (((",
expectedErrShouldContain: "syntax error",
},
{
subtestName: "LikeDanglingEscape_Rejected",
dslQueryToCompile: `name LIKE 'prod\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "ILikeDanglingEscape_Rejected",
dslQueryToCompile: `name ILIKE '%\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "LabelLikeDanglingEscape_Rejected",
dslQueryToCompile: `labels.team NOT LIKE 'infra\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
})
}
func TestCompileTrailingLiteralBackslash(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "EscapedTrailingBackslash_Compiles",
dslQueryToCompile: `name LIKE '%\\\\'`,
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."name"'), '') LIKE ? ESCAPE '\')`,
expectedArgs: []any{`%\\`, `%\\`},
},
})
}
// Guards that every ruletypes.ReservedOps key has a case in resolveReservedKey.
func TestCompileReservedKeysAllHandled(t *testing.T) {
sampleQueries := map[ruletypes.DSLKey]string{
ruletypes.DSLKeyName: "name = 'x'",
ruletypes.DSLKeySeverity: "severity = 'critical'",
ruletypes.DSLKeyCreatedBy: "created_by = 'x'",
ruletypes.DSLKeyUpdatedBy: "updated_by = 'x'",
ruletypes.DSLKeyCreatedAt: "created_at >= '2026-01-02T15:04:05Z'",
ruletypes.DSLKeyUpdatedAt: "updated_at >= '2026-01-02T15:04:05Z'",
ruletypes.DSLKeyAlertType: "alert_type = 'METRIC_BASED_ALERT'",
ruletypes.DSLKeyRuleType: "rule_type = 'threshold_rule'",
}
for key := range ruletypes.ReservedOps {
query, ok := sampleQueries[key]
require.True(t, ok, "no sample query for reserved key %q, add one", key)
out, err := CompileListFilter(query, formatter(t))
require.NoError(t, err, "reserved key %q failed to compile", key)
assert.False(t, out.IsEmpty(), "reserved key %q compiled to empty SQL", key)
}
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -851,6 +851,8 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
// initiate response object
resp := make([]*ruletypes.GettableRule, 0)
stateByRuleID := m.snapshotRuleStates()
for _, s := range storedRules {
ruleResponse := ruletypes.GettableRule{}
@@ -863,11 +865,11 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
ruleResponse.Id = s.ID.StringValue()
// fetch state of rule from memory
if rm, ok := m.rules[ruleResponse.Id]; !ok {
if state, ok := stateByRuleID[ruleResponse.Id]; !ok {
ruleResponse.State = ruletypes.StateDisabled
ruleResponse.Disabled = true
} else {
ruleResponse.State = rm.State()
ruleResponse.State = state
}
ruleResponse.CreatedAt = s.CreatedAt
ruleResponse.CreatedBy = &s.CreatedBy
@@ -879,6 +881,71 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
return &ruletypes.GettableRules{Rules: resp}, nil
}
// ListRules' total counts what is pageable after corrupt-row drops and the states filter.
func (m *Manager) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
// validated here too, not just in the handler: non-API callers reach the manager directly
if err := params.Validate(); err != nil {
return nil, err
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
states, err := params.GetAlertStates()
if err != nil {
return nil, err
}
stateFilter := make(map[ruletypes.AlertState]struct{}, len(states))
for _, state := range states {
stateFilter[state] = struct{}{}
}
compiled, err := CompileListFilter(params.Query, m.sqlstore.Formatter())
if err != nil {
return nil, err
}
storedRules, err := m.ruleStore.GetStoredRulesMatching(ctx, claims.OrgID, compiled.SQL, compiled.Args)
if err != nil {
return nil, err
}
stateByRuleID := m.snapshotRuleStates()
listableRules, errByRuleID := ruletypes.NewListableRulesFromStorableRules(storedRules, stateByRuleID, stateFilter)
for ruleID, err := range errByRuleID {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", ruleID), errors.Attr(err))
}
total := int64(len(listableRules))
ruletypes.SortListableRules(listableRules, params.Sort, params.Order)
start := min(params.Offset, len(listableRules))
end := min(start+params.Limit, len(listableRules))
currentPageRules := listableRules[start:end]
rawLabels, err := m.ruleStore.GetStoredRuleLabels(ctx, claims.OrgID)
if err != nil {
return nil, err
}
labelPairs := ruletypes.NewLabelPairsFromRawJSON(rawLabels, ruletypes.MaxListLabelPairs)
return ruletypes.NewListableRules(currentPageRules, total, labelPairs), nil
}
func (m *Manager) snapshotRuleStates() map[string]ruletypes.AlertState {
m.mtx.RLock()
defer m.mtx.RUnlock()
states := make(map[string]ruletypes.AlertState, len(m.rules))
for id, rule := range m.rules {
states[id] = rule.State()
}
return states
}
func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {

View File

@@ -20,6 +20,7 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -28,6 +29,17 @@ import (
cmock "github.com/SigNoz/clickhouse-go-mock"
)
func TestManager_ListRules_ValidatesParams(t *testing.T) {
m, err := NewManager(&ManagerOptions{})
require.NoError(t, err)
_, err = m.ListRules(context.Background(), &ruletypes.ListRulesParams{Limit: -1})
require.ErrorContains(t, err, "invalid limit")
_, err = m.ListRules(context.Background(), &ruletypes.ListRulesParams{States: []string{"bogus"}})
require.ErrorContains(t, err, `invalid state "bogus"`)
}
func TestManager_TestNotification_SendUnmatched_ThresholdRule(t *testing.T) {
target := 10.0
recovery := 5.0

View File

@@ -4,6 +4,7 @@ import "net/http"
type Handler interface {
ListRules(http.ResponseWriter, *http.Request)
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)

View File

@@ -17,6 +17,9 @@ type Ruler interface {
// ListRuleStates returns all rules with their current evaluation state.
ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error)
// ListRules returns a filtered, sorted page of rules with state, plus label pairs and reserved filter keys.
ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error)
// GetRule returns a single rule by ID.
GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)

View File

@@ -64,6 +64,16 @@ func (m *MockSQLRuleStore) GetStoredRules(ctx context.Context, orgID string) ([]
return m.ruleStore.GetStoredRules(ctx, orgID)
}
// GetStoredRulesMatching implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) GetStoredRulesMatching(ctx context.Context, orgID string, filterSQL string, filterArgs []any) ([]*ruletypes.StorableRule, error) {
return m.ruleStore.GetStoredRulesMatching(ctx, orgID, filterSQL, filterArgs)
}
// GetStoredRuleLabels implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
func (m *MockSQLRuleStore) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
return m.ruleStore.GetStoredRuleLabels(ctx, orgID)
}
// GetStoredRulesByMetricName implements ruletypes.RuleStore - delegates to underlying ruleStore.
func (m *MockSQLRuleStore) GetStoredRulesByMetricName(ctx context.Context, orgID string, metricName string) ([]ruletypes.RuleAlert, error) {
return m.ruleStore.GetStoredRulesByMetricName(ctx, orgID, metricName)

View File

@@ -3,6 +3,7 @@ package sqlrulestore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
@@ -89,6 +90,41 @@ func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return nil
}
func (r *rule) GetStoredRulesMatching(ctx context.Context, orgID string, filterSQL string, filterArgs []any) ([]*ruletypes.StorableRule, error) {
rules := make([]*ruletypes.StorableRule, 0)
q := r.sqlstore.
BunDB().
NewSelect().
Model(&rules).
Where("org_id = ?", orgID)
if filterSQL != "" {
q = q.Where(filterSQL, filterArgs...)
}
if err := q.Scan(ctx); err != nil {
return nil, err
}
return rules, nil
}
func (r *rule) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
labelsExpression := string(r.sqlstore.Formatter().JSONExtractString("rule.data", "$.labels"))
labels := make([]string, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model((*ruletypes.StorableRule)(nil)).
ColumnExpr(fmt.Sprintf("COALESCE(%s, '')", labelsExpression)).
Where("org_id = ?", orgID).
Scan(ctx, &labels)
if err != nil {
return nil, err
}
return labels, nil
}
func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.StorableRule, error) {
rules := make([]*ruletypes.StorableRule, 0)
err := r.sqlstore.

View File

@@ -43,6 +43,29 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
params := new(ruletypes.ListRulesParams)
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
listableRules, err := handler.ruler.ListRules(ctx, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, listableRules)
}
func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -116,6 +116,10 @@ func (provider *provider) ListRuleStates(ctx context.Context) (*ruletypes.Gettab
return provider.manager.ListRuleStates(ctx)
}
func (provider *provider) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
return provider.manager.ListRules(ctx, params)
}
func (provider *provider) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
return provider.manager.GetRule(ctx, id)
}

View File

@@ -1,6 +1,7 @@
package sqlitesqlstore
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -25,6 +26,12 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return sql
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
// Quote the key as one path segment; a double quote in it is inexpressible in sqlite JSON paths.
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "json_type("...)

View File

@@ -55,6 +55,60 @@ func TestJSONExtractString(t *testing.T) {
}
}
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "PlainKey",
column: "data",
mapField: "labels",
key: "team",
expected: `json_extract("data", '$.labels."team"')`,
},
{
name: "DottedKey_OneMapEntry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `json_extract("data", '$.labels."k8s.cluster"')`,
},
{
name: "BackslashInKey_Escaped",
column: "data",
mapField: "labels",
key: `a\b`,
expected: `json_extract("data", '$.labels."a\\b"')`,
},
{
name: "SingleQuoteInKey_Doubled",
column: "data",
mapField: "labels",
key: "o'brien",
expected: `json_extract("data", '$.labels."o''brien"')`,
},
{
name: "QualifiedColumn",
column: "rule.data",
mapField: "labels",
key: "severity",
expected: `json_extract("rule"."data", '$.labels."severity"')`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(sqlitedialect.New())
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string

View File

@@ -114,6 +114,9 @@ type SQLFormatter interface {
// JSONKeys return extracted key from json as well as alias to be used for select and where clause
JSONKeys(column, path, alias string) ([]byte, []byte)
// JSONExtractMapValue extracts one key's value from a JSON object field; dots in the key are not path nesting.
JSONExtractMapValue(column, mapField, key string) []byte
// TextToJsonColumn converts a text column to JSON type
TextToJsonColumn(column string) []byte

View File

@@ -1,6 +1,7 @@
package sqlstoretest
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -25,6 +26,11 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
return sql
}
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
escapedKey := strings.NewReplacer(`\`, `\\`).Replace(key)
return f.JSONExtractString(column, fmt.Sprintf(`$.%s."%s"`, mapField, escapedKey))
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "json_type("...)

View File

@@ -0,0 +1,62 @@
package sqlstoretest
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uptrace/bun/dialect/sqlitedialect"
)
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "PlainKey",
column: "data",
mapField: "labels",
key: "team",
expected: `json_extract("data", '$.labels."team"')`,
},
{
name: "DottedKey_OneMapEntry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `json_extract("data", '$.labels."k8s.cluster"')`,
},
{
name: "BackslashInKey_Escaped",
column: "data",
mapField: "labels",
key: `a\b`,
expected: `json_extract("data", '$.labels."a\\b"')`,
},
{
name: "SingleQuoteInKey_Doubled",
column: "data",
mapField: "labels",
key: "o'brien",
expected: `json_extract("data", '$.labels."o''brien"')`,
},
{
name: "QualifiedColumn",
column: "rule.data",
mapField: "labels",
key: "severity",
expected: `json_extract("rule"."data", '$.labels."severity"')`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(sqlitedialect.New())
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -38,3 +38,18 @@ var alertStateSeverity = map[AlertState]int{
func (a AlertState) Severity() int {
return alertStateSeverity[a]
}
// Display priority for list sorting, worst first from a user's view; deliberately
// NOT Severity(), which ranks disabled/nodata above firing for overall-state computation.
var alertStateDisplayRank = map[AlertState]int{
StateFiring: 5,
StateNoData: 4,
StatePending: 3,
StateRecovering: 2,
StateInactive: 1,
StateDisabled: 0,
}
func (a AlertState) DisplayRank() int {
return alertStateDisplayRank[a]
}

View File

@@ -0,0 +1,21 @@
package ruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
// Both rankings must stay exhaustive: a new AlertState needs an entry in each.
func TestAlertStateRankingsAreExhaustive(t *testing.T) {
states := AlertState{}.Enum()
assert.Len(t, alertStateSeverity, len(states))
assert.Len(t, alertStateDisplayRank, len(states))
for _, s := range states {
state := s.(AlertState)
assert.Contains(t, alertStateSeverity, state, "missing severity for state %q", state)
assert.Contains(t, alertStateDisplayRank, state, "missing display rank for state %q", state)
}
}

129
pkg/types/ruletypes/list.go Normal file
View File

@@ -0,0 +1,129 @@
package ruletypes
import (
"slices"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
DefaultListLimit = 20
MaxListLimit = 200
MaxListQueryLen = 1024
)
var ErrCodeRuleListInvalid = errors.MustNewCode("rule_list_invalid")
type ListSort struct{ valuer.String }
var (
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
ListSortName = ListSort{valuer.NewString("name")}
ListSortState = ListSort{valuer.NewString("state")}
ListSortSeverity = ListSort{valuer.NewString("severity")}
)
func (ListSort) Enum() []any {
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName, ListSortState, ListSortSeverity}
}
func (s ListSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
type ListOrder struct{ valuer.String }
var (
ListOrderAsc = ListOrder{valuer.NewString("asc")}
ListOrderDesc = ListOrder{valuer.NewString("desc")}
)
func (ListOrder) Enum() []any {
return []any{ListOrderAsc, ListOrderDesc}
}
func (o ListOrder) IsValid() bool {
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
}
type ListRulesParams struct {
Query string `query:"query"`
// gin cannot bind a slice of valuer enums; AlertStates converts these.
States []string `query:"states"`
Sort ListSort `query:"sort"`
Order ListOrder `query:"order"`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
// Validate normalizes in place; an over-max limit is clamped, not rejected.
func (p *ListRulesParams) Validate() error {
if n := utf8.RuneCountInString(p.Query); n > MaxListQueryLen {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"query cannot be longer than %d characters, got %d", MaxListQueryLen, n)
}
if p.Sort.IsZero() {
p.Sort = ListSortUpdatedAt
} else if !p.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid sort %q, expected one of: `updated_at`, `created_at`, `name`, `state`, `severity`", p.Sort)
}
if p.Order.IsZero() {
p.Order = ListOrderDesc
} else if !p.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid order %q, expected `asc` or `desc`", p.Order)
}
if p.Limit == 0 {
p.Limit = DefaultListLimit
} else if p.Limit < 0 {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid limit %d, must be a positive integer", p.Limit)
} else if p.Limit > MaxListLimit {
p.Limit = MaxListLimit
}
if p.Offset < 0 {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid offset %d, must be a non-negative integer", p.Offset)
}
if _, err := p.GetAlertStates(); err != nil {
return err
}
return nil
}
// GetAlertStates parses States; empty means no state filtering.
func (p *ListRulesParams) GetAlertStates() ([]AlertState, error) {
if len(p.States) == 0 {
return nil, nil
}
states := make([]AlertState, 0, len(p.States))
for _, raw := range p.States {
state, err := parseAlertState(raw)
if err != nil {
return nil, err
}
states = append(states, state)
}
return states, nil
}
func parseAlertState(raw string) (AlertState, error) {
state := AlertState{valuer.NewString(raw)}
if !slices.Contains(state.Enum(), any(state)) {
return AlertState{}, errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid state %q, expected one of: `firing`, `pending`, `recovering`, `inactive`, `nodata`, `disabled`", raw)
}
return state, nil
}

View File

@@ -0,0 +1,100 @@
package ruletypes
import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeRuleListFilterInvalid = errors.MustNewCode("rule_list_filter_invalid")
// DSLKey is a reserved (column-level) key in the rule list filter DSL.
type DSLKey string
const (
DSLKeyName DSLKey = "name"
DSLKeySeverity DSLKey = "severity"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyUpdatedBy DSLKey = "updated_by"
DSLKeyCreatedAt DSLKey = "created_at"
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyAlertType DSLKey = "alert_type"
DSLKeyRuleType DSLKey = "rule_type"
// Label keys under this prefix are matched exactly (case-sensitive).
DSLLabelsKeyPrefix = "labels."
// Advertised in reservedKeywords; not itself a filterable key.
DSLKeyLabelsPlaceholder DSLKey = "labels.<key>"
)
func ReservedFilterKeys() []DSLKey {
keys := make([]DSLKey, 0, len(ReservedOps)+1)
for key := range ReservedOps {
keys = append(keys, key)
}
keys = append(keys, DSLKeyLabelsPlaceholder)
slices.SortFunc(keys, func(a, b DSLKey) int {
return strings.Compare(string(a), string(b))
})
return keys
}
// ReservedOps lists the operators each reserved DSL key accepts; `labels.<key>` terms use LabelsKeyOps.
var ReservedOps = map[DSLKey]map[qbtypesv5.FilterOperator]struct{}{
DSLKeyName: stringSearchOps(),
// severity aliases labels.severity, so it takes the labels operator set.
DSLKeySeverity: LabelsKeyOps,
DSLKeyCreatedBy: stringSearchOps(),
DSLKeyUpdatedBy: stringSearchOps(),
DSLKeyCreatedAt: numericRangeOps(),
DSLKeyUpdatedAt: numericRangeOps(),
DSLKeyAlertType: enumOps(),
DSLKeyRuleType: enumOps(),
}
// LabelsKeyOps operators target the label's value; EXISTS/NOT EXISTS test its presence.
var LabelsKeyOps = opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
)
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
)
}
func enumOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
for _, op := range ops {
m[op] = struct{}{}
}
return m
}

View File

@@ -0,0 +1,34 @@
package ruletypes
import (
"testing"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestReservedFilterKeys(t *testing.T) {
assert.Equal(t, []DSLKey{
DSLKeyAlertType,
DSLKeyCreatedAt,
DSLKeyCreatedBy,
DSLKeyLabelsPlaceholder,
DSLKeyName,
DSLKeyRuleType,
DSLKeySeverity,
DSLKeyUpdatedAt,
DSLKeyUpdatedBy,
}, ReservedFilterKeys())
}
func TestFilterOpsExcludeRegexp(t *testing.T) {
for key, ops := range ReservedOps {
assert.NotEmpty(t, ops, "key %q has no operators", key)
assert.NotContains(t, ops, qbtypesv5.FilterOperatorRegexp, "key %q allows REGEXP", key)
assert.NotContains(t, ops, qbtypesv5.FilterOperatorNotRegexp, "key %q allows NOT REGEXP", key)
}
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorRegexp)
assert.NotContains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotRegexp)
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorExists)
assert.Contains(t, LabelsKeyOps, qbtypesv5.FilterOperatorNotExists)
}

View File

@@ -0,0 +1,126 @@
package ruletypes
import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListRulesParamsValidate(t *testing.T) {
testCases := []struct {
name string
params ListRulesParams
wantErr string
wantSort ListSort
wantOrder ListOrder
wantLimit int
}{
{
name: "EmptyParams_Defaults",
params: ListRulesParams{},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: DefaultListLimit,
},
{
name: "ExplicitValues_Kept",
params: ListRulesParams{Sort: ListSortSeverity, Order: ListOrderAsc, Limit: 50, Offset: 100},
wantSort: ListSortSeverity,
wantOrder: ListOrderAsc,
wantLimit: 50,
},
{
name: "OverMaxLimit_Clamped",
params: ListRulesParams{Limit: MaxListLimit + 1},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: MaxListLimit,
},
{
name: "InvalidState_Rejected",
params: ListRulesParams{States: []string{"bogus"}},
wantErr: `invalid state "bogus"`,
},
{
name: "InvalidSort_Rejected",
params: ListRulesParams{Sort: ListSort{valuer.NewString("bogus")}},
wantErr: "invalid sort",
},
{
name: "InvalidOrder_Rejected",
params: ListRulesParams{Order: ListOrder{valuer.NewString("bogus")}},
wantErr: "invalid order",
},
{
name: "NegativeLimit_Rejected",
params: ListRulesParams{Limit: -1},
wantErr: "invalid limit",
},
{
name: "NegativeOffset_Rejected",
params: ListRulesParams{Offset: -1},
wantErr: "invalid offset",
},
{
name: "OverLongQuery_Rejected",
params: ListRulesParams{Query: strings.Repeat("a", MaxListQueryLen+1)},
wantErr: "query cannot be longer",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.params.Validate()
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantSort, tc.params.Sort)
assert.Equal(t, tc.wantOrder, tc.params.Order)
assert.Equal(t, tc.wantLimit, tc.params.Limit)
})
}
}
func TestListRulesParamsAlertStates(t *testing.T) {
testCases := []struct {
name string
states []string
wantErr string
wantStates []AlertState
}{
{
name: "ValidStates_ParsedToTypedValues",
states: []string{"firing", "pending"},
wantStates: []AlertState{StateFiring, StatePending},
},
{
name: "AbsentStates_NoFiltering",
states: nil,
},
{
name: "InvalidState_Rejected",
states: []string{"bogus"},
wantErr: `invalid state "bogus"`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
params := ListRulesParams{States: tc.states}
states, err := params.GetAlertStates()
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantStates, states)
})
}
}

View File

@@ -0,0 +1,190 @@
package ruletypes
import (
"cmp"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/types"
)
const MaxListLabelPairs = 1000
// ListableRule is the slim per-row shape of the list endpoint; the full rule stays behind get-by-id.
type ListableRule struct {
Id string `json:"id" required:"true"`
State AlertState `json:"state" required:"true"`
AlertName string `json:"alert" required:"true"`
Description string `json:"description,omitempty"`
AlertType AlertType `json:"alertType" required:"true"`
RuleType RuleType `json:"ruleType" required:"true"`
Disabled bool `json:"disabled"`
Labels map[string]string `json:"labels,omitempty"`
types.TimeAuditable
types.UserAuditable
}
// storedRuleData is the subset of the persisted rule data blob the list page needs.
type storedRuleData struct {
AlertName string `json:"alert"`
Description string `json:"description"`
AlertType AlertType `json:"alertType"`
RuleType RuleType `json:"ruleType"`
Disabled bool `json:"disabled"`
Labels map[string]string `json:"labels"`
}
// ToListableRule leaves State zero; the caller overlays evaluation state.
func (rule *StorableRule) ToListableRule() (*ListableRule, error) {
data := storedRuleData{}
if err := json.Unmarshal([]byte(rule.Data), &data); err != nil {
return nil, err
}
return &ListableRule{
Id: rule.ID.StringValue(),
AlertName: data.AlertName,
Description: data.Description,
AlertType: data.AlertType,
RuleType: data.RuleType,
Disabled: data.Disabled,
Labels: data.Labels,
TimeAuditable: rule.TimeAuditable,
UserAuditable: rule.UserAuditable,
}, nil
}
// NewListableRulesFromStorableRules converts rows, overlays evaluation state (absent means
// disabled) and applies the state filter; corrupt rows come back keyed by rule id for the
// caller to log.
func NewListableRulesFromStorableRules(storedRules []*StorableRule, stateByRuleID map[string]AlertState, stateFilter map[AlertState]struct{}) ([]*ListableRule, map[string]error) {
listableRules := make([]*ListableRule, 0, len(storedRules))
errByRuleID := make(map[string]error)
for _, rule := range storedRules {
listable, err := rule.ToListableRule()
if err != nil {
errByRuleID[rule.ID.StringValue()] = err
continue
}
if state, ok := stateByRuleID[listable.Id]; ok {
listable.State = state
} else {
listable.State = StateDisabled
listable.Disabled = true
}
if len(stateFilter) > 0 {
if _, ok := stateFilter[listable.State]; !ok {
continue
}
}
listableRules = append(listableRules, listable)
}
return listableRules, errByRuleID
}
// LabelPair is one distinct label key/value observed on the org's rules.
type LabelPair struct {
Key string `json:"key" required:"true"`
Value string `json:"value" required:"true"`
}
type ListableRules struct {
Rules []*ListableRule `json:"rules" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Labels []LabelPair `json:"labels" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableRules(rules []*ListableRule, total int64, labels []LabelPair) *ListableRules {
return &ListableRules{
Rules: rules,
Total: total,
Labels: labels,
ReservedKeywords: ReservedFilterKeys(),
}
}
var severityDisplayRank = map[string]int{
"critical": 4,
"error": 3,
"warning": 2,
"info": 1,
}
// Ties break on name then id ascending (order applies to the primary key only) so pages stay stable.
func SortListableRules(rules []*ListableRule, sortBy ListSort, order ListOrder) {
direction := 1
if order == ListOrderDesc {
direction = -1
}
slices.SortStableFunc(rules, func(a, b *ListableRule) int {
if c := direction * compareListableRules(a, b, sortBy); c != 0 {
return c
}
if c := strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName)); c != 0 {
return c
}
return strings.Compare(a.Id, b.Id)
})
}
func compareListableRules(a, b *ListableRule, sortBy ListSort) int {
switch sortBy {
case ListSortName:
return strings.Compare(strings.ToLower(a.AlertName), strings.ToLower(b.AlertName))
case ListSortCreatedAt:
return a.CreatedAt.Compare(b.CreatedAt)
case ListSortState:
return cmp.Compare(a.State.DisplayRank(), b.State.DisplayRank())
case ListSortSeverity:
severityA := a.Labels["severity"]
severityB := b.Labels["severity"]
rankA := severityDisplayRank[strings.ToLower(severityA)]
rankB := severityDisplayRank[strings.ToLower(severityB)]
if rankA != rankB {
return cmp.Compare(rankA, rankB)
}
if rankA == 0 {
return strings.Compare(strings.ToLower(severityA), strings.ToLower(severityB))
}
return 0
}
return a.UpdatedAt.Compare(b.UpdatedAt)
}
// NewLabelPairsFromRawJSON skips blank or malformed entries and caps the result at limit.
func NewLabelPairsFromRawJSON(raws []string, limit int) []LabelPair {
set := make(map[LabelPair]struct{})
for _, raw := range raws {
if raw == "" || raw == "null" {
continue
}
labels := make(map[string]string)
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
continue
}
for key, value := range labels {
set[LabelPair{Key: key, Value: value}] = struct{}{}
}
}
pairs := make([]LabelPair, 0, len(set))
for pair := range set {
pairs = append(pairs, pair)
}
slices.SortFunc(pairs, func(a, b LabelPair) int {
if c := strings.Compare(a.Key, b.Key); c != 0 {
return c
}
return strings.Compare(a.Value, b.Value)
})
if len(pairs) > limit {
pairs = pairs[:limit]
}
return pairs
}

View File

@@ -0,0 +1,261 @@
package ruletypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func listableRule(name string, state AlertState, severity string, updatedAt time.Time) *ListableRule {
rule := &ListableRule{
AlertName: name,
State: state,
TimeAuditable: types.TimeAuditable{
UpdatedAt: updatedAt,
},
}
if severity != "" {
rule.Labels = map[string]string{"severity": severity}
}
return rule
}
func names(rules []*ListableRule) []string {
out := make([]string, 0, len(rules))
for _, rule := range rules {
out = append(out, rule.AlertName)
}
return out
}
func TestToListableRule(t *testing.T) {
created := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
updated := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
storable := &StorableRule{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: created, UpdatedAt: updated},
UserAuditable: types.UserAuditable{CreatedBy: "creator@signoz.io", UpdatedBy: "updater@signoz.io"},
Data: `{"alert":"High CPU","description":"cpu is hot","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule","disabled":true,"labels":{"severity":"critical"}}`,
}
listable, err := storable.ToListableRule()
require.NoError(t, err)
assert.Equal(t, storable.ID.StringValue(), listable.Id)
assert.Equal(t, "High CPU", listable.AlertName)
assert.Equal(t, "cpu is hot", listable.Description)
assert.Equal(t, AlertTypeMetric, listable.AlertType)
assert.Equal(t, RuleTypeThreshold, listable.RuleType)
assert.True(t, listable.Disabled)
assert.Equal(t, map[string]string{"severity": "critical"}, listable.Labels)
assert.Equal(t, created, listable.CreatedAt)
assert.Equal(t, "creator@signoz.io", listable.CreatedBy)
assert.Equal(t, updated, listable.UpdatedAt)
assert.Equal(t, "updater@signoz.io", listable.UpdatedBy)
assert.True(t, listable.State.IsZero())
_, err = (&StorableRule{Data: "not json"}).ToListableRule()
assert.Error(t, err)
}
func TestNewListableRulesFromStorableRules(t *testing.T) {
enabledID := valuer.GenerateUUID()
pausedID := valuer.GenerateUUID()
corruptID := valuer.GenerateUUID()
storedRules := []*StorableRule{
{
Identifiable: types.Identifiable{ID: enabledID},
Data: `{"alert":"cpu high","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule"}`,
},
{
Identifiable: types.Identifiable{ID: pausedID},
Data: `{"alert":"mem high","alertType":"METRIC_BASED_ALERT","ruleType":"threshold_rule","disabled":true}`,
},
{
Identifiable: types.Identifiable{ID: corruptID},
Data: "not json",
},
}
stateByRuleID := map[string]AlertState{enabledID.StringValue(): StateFiring}
testCases := []struct {
name string
stateFilter map[AlertState]struct{}
wantNames []string
wantStates map[string]AlertState
}{
{
name: "NoFilter_KeepsAllParseableRows",
wantNames: []string{"cpu high", "mem high"},
wantStates: map[string]AlertState{"cpu high": StateFiring, "mem high": StateDisabled},
},
{
name: "FiringFilter_KeepsOverlaidState",
stateFilter: map[AlertState]struct{}{StateFiring: {}},
wantNames: []string{"cpu high"},
wantStates: map[string]AlertState{"cpu high": StateFiring},
},
{
name: "DisabledFilter_KeepsAbsentFromSnapshot",
stateFilter: map[AlertState]struct{}{StateDisabled: {}},
wantNames: []string{"mem high"},
wantStates: map[string]AlertState{"mem high": StateDisabled},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
listableRules, errByRuleID := NewListableRulesFromStorableRules(storedRules, stateByRuleID, testCase.stateFilter)
require.Len(t, errByRuleID, 1)
assert.Error(t, errByRuleID[corruptID.StringValue()])
assert.Equal(t, testCase.wantNames, names(listableRules))
for _, rule := range listableRules {
assert.Equal(t, testCase.wantStates[rule.AlertName], rule.State)
}
})
}
disabledRow, _ := NewListableRulesFromStorableRules(storedRules[1:2], stateByRuleID, nil)
require.Len(t, disabledRow, 1)
assert.True(t, disabledRow[0].Disabled)
}
func TestSortListableRules(t *testing.T) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
testCases := []struct {
name string
rules []*ListableRule
sortBy ListSort
order ListOrder
wantNames []string
}{
{
name: "StateDesc_DisplayPriority_FiringFirst",
rules: []*ListableRule{
listableRule("disabled", StateDisabled, "", base),
listableRule("nodata", StateNoData, "", base),
listableRule("firing", StateFiring, "", base),
listableRule("inactive", StateInactive, "", base),
listableRule("pending", StatePending, "", base),
listableRule("recovering", StateRecovering, "", base),
},
sortBy: ListSortState,
order: ListOrderDesc,
wantNames: []string{"firing", "nodata", "pending", "recovering", "inactive", "disabled"},
},
{
name: "SeverityDesc_KnownRanksThenCustomLexical",
rules: []*ListableRule{
listableRule("warn", StateInactive, "warning", base),
listableRule("custom-b", StateInactive, "bbb", base),
listableRule("crit", StateInactive, "critical", base),
listableRule("custom-a", StateInactive, "aaa", base),
listableRule("none", StateInactive, "", base),
},
sortBy: ListSortSeverity,
order: ListOrderDesc,
// desc flips the lexical compare between custom values too
wantNames: []string{"crit", "warn", "custom-b", "custom-a", "none"},
},
{
name: "NameAsc_CaseInsensitive",
rules: []*ListableRule{
listableRule("banana", StateInactive, "", base),
listableRule("Apple", StateInactive, "", base),
listableRule("cherry", StateInactive, "", base),
},
sortBy: ListSortName,
order: ListOrderAsc,
wantNames: []string{"Apple", "banana", "cherry"},
},
{
name: "UpdatedAtDesc_NewestFirst",
rules: []*ListableRule{
listableRule("old", StateInactive, "", base),
listableRule("new", StateInactive, "", base.Add(time.Hour)),
},
sortBy: ListSortUpdatedAt,
order: ListOrderDesc,
wantNames: []string{"new", "old"},
},
{
name: "StateDescTies_BreakOnNameAsc",
rules: []*ListableRule{
listableRule("banana", StateFiring, "", base),
listableRule("zebra", StateDisabled, "", base),
listableRule("Apple", StateFiring, "", base),
listableRule("cherry", StateFiring, "", base),
},
sortBy: ListSortState,
order: ListOrderDesc,
wantNames: []string{"Apple", "banana", "cherry", "zebra"},
},
{
name: "StateAsc_FlipsBuckets_TiebreakNameAsc",
rules: []*ListableRule{
listableRule("banana", StateFiring, "", base),
listableRule("zebra", StateDisabled, "", base),
listableRule("Apple", StateFiring, "", base),
listableRule("cherry", StateFiring, "", base),
},
sortBy: ListSortState,
order: ListOrderAsc,
wantNames: []string{"zebra", "Apple", "banana", "cherry"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
SortListableRules(tc.rules, tc.sortBy, tc.order)
assert.Equal(t, tc.wantNames, names(tc.rules))
})
}
}
func TestSortListableRulesIdTiebreak(t *testing.T) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
for _, order := range []ListOrder{ListOrderAsc, ListOrderDesc} {
t.Run(order.StringValue(), func(t *testing.T) {
older := listableRule("dup", StateFiring, "", base)
older.Id = "01aaa"
newer := listableRule("dup", StateFiring, "", base)
newer.Id = "01bbb"
rules := []*ListableRule{newer, older}
SortListableRules(rules, ListSortState, order)
assert.Equal(t, []string{"01aaa", "01bbb"}, []string{rules[0].Id, rules[1].Id})
})
}
}
func TestNewLabelPairsFromRawJSON(t *testing.T) {
pairs := NewLabelPairsFromRawJSON([]string{
`{"team":"infra","severity":"critical"}`,
`{"team":"infra"}`,
`{"team":"payments"}`,
"",
"null",
"not-json",
}, MaxListLabelPairs)
assert.Equal(t, []LabelPair{
{Key: "severity", Value: "critical"},
{Key: "team", Value: "infra"},
{Key: "team", Value: "payments"},
}, pairs)
}
func TestNewLabelPairsFromRawJSONCap(t *testing.T) {
pairs := NewLabelPairsFromRawJSON([]string{`{"a":"1","b":"2","c":"3"}`}, 2)
assert.Len(t, pairs, 2)
}

View File

@@ -11,7 +11,8 @@ import (
)
type StorableRule struct {
bun.BaseModel `bun:"table:rule"`
// The alias must stay rule: the list filter compiler emits rule.<col> refs.
bun.BaseModel `bun:"table:rule,alias:rule"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
@@ -58,6 +59,10 @@ type RuleStore interface {
EditRule(context.Context, *StorableRule, func(context.Context) error) error
DeleteRule(context.Context, valuer.UUID, valuer.UUID, func(context.Context) error) error
GetStoredRules(context.Context, string) ([]*StorableRule, error)
// GetStoredRulesMatching returns the org's rules matching a compiled filter clause; an empty clause matches all.
GetStoredRulesMatching(context.Context, string, string, []any) ([]*StorableRule, error)
// GetStoredRuleLabels returns each rule's labels as raw JSON text, empty string when absent.
GetStoredRuleLabels(context.Context, string) ([]string, error)
GetStoredRule(context.Context, valuer.UUID, valuer.UUID) (*StorableRule, error)
GetStoredRulesByMetricName(context.Context, string, string) ([]RuleAlert, error)
}

View File

@@ -88,6 +88,49 @@ def create_alert_rule_with_channel(
return _create_alert_rule_with_channel
def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
for rule in response.json()["data"]:
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert delete_response.status_code == HTTPStatus.OK, f"failed to delete rule {rule['id']}: {delete_response.text}"
@pytest.fixture(name="seed_alert_rules", scope="function")
def seed_alert_rules(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
) -> Callable[[str, list[dict]], None]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create_notification_channel rather than create_webhook_notification_channel:
# only the former deletes on teardown, and callers reuse one channel name
# across tests, so a leaked channel fails the next create as a duplicate.
def _seed_alert_rules(channel_name: str, rules: list[dict]) -> None:
delete_all_rules(signoz, admin_token)
create_notification_channel(
{
"name": channel_name,
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get(f"/alert/{channel_name}"), "send_resolved": False}],
}
)
for rule in rules:
create_alert_rule(rule)
return _seed_alert_rules
def labels_to_map(labels: list[dict]) -> dict[str, str]:
"""Converts the label list shape of the v2 rule history APIs to a plain map."""
return {label["key"]["name"]: label["value"] for label in labels or []}

View File

@@ -31,11 +31,11 @@ logger = setup_logger(__name__)
NOTIFIERS_TEST = [
types.AlertManagerNotificationTestCase(
name="slack_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=slack_default_config,
@@ -64,11 +64,11 @@ NOTIFIERS_TEST = [
),
types.AlertManagerNotificationTestCase(
name="msteams_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=msteams_default_config,
@@ -149,11 +149,11 @@ NOTIFIERS_TEST = [
),
types.AlertManagerNotificationTestCase(
name="pagerduty_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=pagerduty_default_config,
@@ -194,11 +194,11 @@ NOTIFIERS_TEST = [
),
types.AlertManagerNotificationTestCase(
name="opsgenie_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=opsgenie_default_config,
@@ -226,11 +226,11 @@ NOTIFIERS_TEST = [
),
types.AlertManagerNotificationTestCase(
name="webhook_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=webhook_default_config,
@@ -275,11 +275,11 @@ NOTIFIERS_TEST = [
),
types.AlertManagerNotificationTestCase(
name="email_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=email_default_config,

View File

@@ -15,10 +15,10 @@ logger = setup_logger(__name__)
def test_webhook_notification_channel(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
) -> None:
logger.info("Setting up notification channel")
@@ -45,14 +45,6 @@ def test_webhook_notification_channel(
],
)
# Create an alert channel using the given route
create_webhook_notification_channel(
channel_name=notification_channel_name,
webhook_url=webhook_endpoint,
http_config={},
send_resolved=True,
)
# TODO: @abhishekhugetech # pylint: disable=W0511
# Time required for newly created Org to be registered in the alertmanager is 5 seconds in signoz.py
# this will be fixed after [https://github.com/SigNoz/engineering-pod/issues/3800]

View File

@@ -21,12 +21,12 @@ from fixtures.logger import setup_logger
TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
types.AlertTestCase(
name="test_threshold_above_at_least_once",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
# active requests dummy data
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -44,11 +44,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_above_all_the_time",
rule_path="alerts/test_scenarios/threshold_above_all_the_time/rule.json",
rule_path="ruler/test_scenarios/threshold_above_all_the_time/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_all_the_time/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_all_the_time/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -66,11 +66,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_above_in_total",
rule_path="alerts/test_scenarios/threshold_above_in_total/rule.json",
rule_path="ruler/test_scenarios/threshold_above_in_total/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_in_total/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_in_total/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -96,11 +96,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_above_average",
rule_path="alerts/test_scenarios/threshold_above_average/rule.json",
rule_path="ruler/test_scenarios/threshold_above_average/rule.json",
alert_data=[
types.AlertData(
type="traces",
data_path="alerts/test_scenarios/threshold_above_average/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_average/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -118,11 +118,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_above_last",
rule_path="alerts/test_scenarios/threshold_above_last/rule.json",
rule_path="ruler/test_scenarios/threshold_above_last/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_last/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_above_last/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -140,11 +140,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_below_at_least_once",
rule_path="alerts/test_scenarios/threshold_below_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_below_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="logs",
data_path="alerts/test_scenarios/threshold_below_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -162,11 +162,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_below_all_the_time",
rule_path="alerts/test_scenarios/threshold_below_all_the_time/rule.json",
rule_path="ruler/test_scenarios/threshold_below_all_the_time/rule.json",
alert_data=[
types.AlertData(
type="logs",
data_path="alerts/test_scenarios/threshold_below_all_the_time/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_below_all_the_time/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -184,12 +184,12 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_below_in_total",
rule_path="alerts/test_scenarios/threshold_below_in_total/rule.json",
rule_path="ruler/test_scenarios/threshold_below_in_total/rule.json",
alert_data=[
types.AlertData(
type="metrics",
# one rate ~5 + rest 0.01 so it remains in total below 10
data_path="alerts/test_scenarios/threshold_below_in_total/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_below_in_total/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -207,11 +207,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_below_average",
rule_path="alerts/test_scenarios/threshold_below_average/rule.json",
rule_path="ruler/test_scenarios/threshold_below_average/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_below_average/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_below_average/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -229,11 +229,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_below_last",
rule_path="alerts/test_scenarios/threshold_below_last/rule.json",
rule_path="ruler/test_scenarios/threshold_below_last/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_below_last/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_below_last/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -251,11 +251,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_equal_to_at_least_once",
rule_path="alerts/test_scenarios/threshold_equal_to_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_equal_to_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_equal_to_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_equal_to_at_least_once/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -273,11 +273,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_equal_to_all_the_time",
rule_path="alerts/test_scenarios/threshold_equal_to_all_the_time/rule.json",
rule_path="ruler/test_scenarios/threshold_equal_to_all_the_time/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_equal_to_all_the_time/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_equal_to_all_the_time/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -295,11 +295,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_equal_to_in_total",
rule_path="alerts/test_scenarios/threshold_equal_to_in_total/rule.json",
rule_path="ruler/test_scenarios/threshold_equal_to_in_total/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_equal_to_in_total/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_equal_to_in_total/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -317,11 +317,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_equal_to_average",
rule_path="alerts/test_scenarios/threshold_equal_to_average/rule.json",
rule_path="ruler/test_scenarios/threshold_equal_to_average/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_equal_to_average/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_equal_to_average/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -339,11 +339,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_equal_to_last",
rule_path="alerts/test_scenarios/threshold_equal_to_last/rule.json",
rule_path="ruler/test_scenarios/threshold_equal_to_last/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_equal_to_last/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_equal_to_last/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -361,11 +361,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_not_equal_to_at_least_once",
rule_path="alerts/test_scenarios/threshold_not_equal_to_at_least_once/rule.json",
rule_path="ruler/test_scenarios/threshold_not_equal_to_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_not_equal_to_at_least_once/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_not_equal_to_at_least_once/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -383,11 +383,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_not_equal_to_all_the_time",
rule_path="alerts/test_scenarios/threshold_not_equal_to_all_the_time/rule.json",
rule_path="ruler/test_scenarios/threshold_not_equal_to_all_the_time/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_not_equal_to_all_the_time/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_not_equal_to_all_the_time/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -405,11 +405,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_not_equal_to_in_total",
rule_path="alerts/test_scenarios/threshold_not_equal_to_in_total/rule.json",
rule_path="ruler/test_scenarios/threshold_not_equal_to_in_total/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_not_equal_to_in_total/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_not_equal_to_in_total/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -427,11 +427,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_not_equal_to_average",
rule_path="alerts/test_scenarios/threshold_not_equal_to_average/rule.json",
rule_path="ruler/test_scenarios/threshold_not_equal_to_average/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_not_equal_to_average/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_not_equal_to_average/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -449,11 +449,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
),
types.AlertTestCase(
name="test_threshold_not_equal_to_last",
rule_path="alerts/test_scenarios/threshold_not_equal_to_last/rule.json",
rule_path="ruler/test_scenarios/threshold_not_equal_to_last/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_not_equal_to_last/alert_data.jsonl",
data_path="ruler/test_scenarios/threshold_not_equal_to_last/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -475,11 +475,11 @@ TEST_RULES_MATCH_TYPE_AND_COMPARE_OPERATORS = [
TEST_RULES_UNIT_CONVERSION = [
types.AlertTestCase(
name="test_unit_conversion_bytes_to_mb",
rule_path="alerts/test_scenarios/unit_conversion_bytes_to_mb/rule.json",
rule_path="ruler/test_scenarios/unit_conversion_bytes_to_mb/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/unit_conversion_bytes_to_mb/alert_data.jsonl",
data_path="ruler/test_scenarios/unit_conversion_bytes_to_mb/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -497,11 +497,11 @@ TEST_RULES_UNIT_CONVERSION = [
),
types.AlertTestCase(
name="test_unit_conversion_ms_to_second",
rule_path="alerts/test_scenarios/unit_conversion_ms_to_second/rule.json",
rule_path="ruler/test_scenarios/unit_conversion_ms_to_second/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/unit_conversion_ms_to_second/alert_data.jsonl",
data_path="ruler/test_scenarios/unit_conversion_ms_to_second/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -523,11 +523,11 @@ TEST_RULES_UNIT_CONVERSION = [
TEST_RULES_MISCELLANEOUS = [
types.AlertTestCase(
name="test_no_data_rule_test",
rule_path="alerts/test_scenarios/no_data_rule_test/rule.json",
rule_path="ruler/test_scenarios/no_data_rule_test/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/no_data_rule_test/alert_data.jsonl",
data_path="ruler/test_scenarios/no_data_rule_test/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
@@ -547,11 +547,11 @@ TEST_RULES_MISCELLANEOUS = [
# after the [issue](https://github.com/SigNoz/engineering-pod/issues/3934) with alertManager is resolved
# types.AlertTestCase(
# name="test_multi_threshold_rule_test",
# rule_path="alerts/test_scenarios/multi_threshold_rule_test/rule.json",
# rule_path="ruler/test_scenarios/multi_threshold_rule_test/rule.json",
# alert_data=[
# types.AlertData(
# type="metrics",
# data_path="alerts/test_scenarios/multi_threshold_rule_test/alert_data.jsonl",
# data_path="ruler/test_scenarios/multi_threshold_rule_test/alert_data.jsonl",
# ),
# ],
# alert_expectation=types.AlertExpectation(

Some files were not shown because too many files have changed in this diff Show More