Compare commits

...

37 Commits

Author SHA1 Message Date
Nikhil Mantri
339a10217e Merge branch 'feat/alerts_listing_page_revamp' into feat/alert_rule_views 2026-09-10 18:57:09 +05:30
Nikhil Mantri
cd0eb62734 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-10 18:56:27 +05:30
nikhilmantri0902
a667007991 Merge remote-tracking branch 'origin/feat/alerts_listing_page_revamp' into feat/alert_rule_views 2026-09-10 16:04:32 +05:30
nikhilmantri0902
208fc1e8ec Merge remote-tracking branch 'origin/feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-10 15:58:38 +05:30
nikhilmantri0902
54eb67392a refactor(filterquery): order the compiler main flow first
Visit methods follow Compile; builders, extractors and operator
spelling sit below with main's section separators.
2026-09-10 15:53:07 +05:30
nikhilmantri0902
0e0d97c16f refactor(filterquery): address review nits on the compiler
Verb-first helper names (BuildStringOperation etc., ResolveFreeText),
Sb and Formatter as exported fields instead of getters, and tests for
the dangling-backslash rejection.
2026-09-10 15:43:58 +05:30
nikhilmantri0902
659f9c7ecf test(ruletypes): assert UpdatedAt strictly advances on view update 2026-09-10 13:09:54 +05:30
nikhilmantri0902
ce8442c17a chore(ruler): drop rationale comments from rule view code 2026-09-10 13:09:53 +05:30
nikhilmantri0902
7ab21678b5 test(alerts): own rule view cleanup in a fixture
The lifecycle test filters lists by its own view names instead of wiping
the org's views and asserting global counts.
2026-09-10 13:02:47 +05:30
nikhilmantri0902
951bfb66cd fix(ruletypes): never return null states on a rule view
Validate normalizes nil states to an empty slice and the field is marked
non-nullable; regenerates the openapi spec and the frontend api client.
2026-09-10 13:02:42 +05:30
nikhilmantri0902
113685fbdb refactor(sqlmigration): create the rule_view index via bun builder
Matches the package's index precedent and idx_ naming.
2026-09-10 12:45:05 +05:30
nikhilmantri0902
b9c306dd26 refactor(ruletypes): share list filter validation between params and views
ListRulesParams and RuleViewData now embed one ListFilter with a single
Validate, mirroring dashboards; filter errors surface as rule_list_invalid.
2026-09-10 12:36:24 +05:30
nikhilmantri0902
7e71d4507c Merge remote-tracking branch 'origin/feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-09 20:10:09 +05:30
nikhilmantri0902
e13cb08097 refactor(filterquery): collapse the builder into the visitor
One Visitor struct now carries sb, fmter and errors like the old
per-feature visitors did; FieldResolver stays the only new concept.
2026-09-09 20:08:33 +05:30
nikhilmantri0902
1e7aaa9f14 feat(ruler): add saved views CRUD for the alerts list page
A saved view stores the v3 list params (query, states, sort, order) in a
new rule_view table and replays them; shapes mirror dashboard views.
2026-09-09 15:57:47 +05:30
nikhilmantri0902
09ab9f4081 Merge branch 'feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-09 11:41:05 +05:30
nikhilmantri0902
f945b5f513 refactor(dashboard): port the list filter to the shared sqlcompiler
The visitor moves to a key-policy resolver plus an error-code wrap;
emitted SQL is unchanged, pinned by the existing exact-SQL unit suite.
2026-09-09 11:33:35 +05:30
nikhilmantri0902
8b0ab0ff26 refactor(filterquery): add shared list filter SQL compiler
Extracted from the dashboards list visitor: grammar walk, operator
dispatch, value extraction, LIKE builders and the Compiled output type,
behind a per-feature FieldResolver. Scope is list pages over the
relational store; telemetry queries stay on querybuilder. Also rejects
LIKE and ILIKE patterns ending in an unescaped backslash, which never
match on sqlite and abort the query on Postgres.
2026-09-09 11:33:34 +05:30
Nikhil Mantri
f2679a6866 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-08 17:47:29 +05:30
nikhilmantri0902
253a117849 refactor: address list API nits
Use fmt.Sprintf over string concatenation in the compiler, rule store
and JSON formatters; build the enum value lists once as package vars;
collapse the repeated integration seed blocks into a seed_alert_rules
fixture and drop name-restating fixture docstrings.
2026-09-08 17:42:46 +05:30
nikhilmantri0902
c9538be38f style: trim list API comments to one line per the repo comment rules 2026-09-08 17:16:18 +05:30
nikhilmantri0902
be51c37317 fix(sqlcompiler): reject LIKE patterns ending in an unescaped backslash
Such a pattern never matches on sqlite and aborts the query on Postgres
when the matcher consumes the dangling escape, turning user input into
a data-dependent 500. Reject it as a 400 on both list endpoints; a
literal trailing backslash stays expressible as an escaped backslash.
Also move the Compiled type into sqlcompiler, leaving each feature only
the error-code wrap.
2026-09-08 17:16:11 +05:30
nikhilmantri0902
66796d1767 refactor: extract shared filter query SQL compiler from dashboards and rules
The dashboards and rules list visitors were near-identical: boolean
composition, operator dispatch, value extraction and the LIKE family
builders. Move that core to pkg/parser/filterquery/sqlcompiler behind a
FieldResolver interface; each feature keeps only its key policy. Scope
is the bun-managed relational store, telemetry stays on querybuilder.
Verified by both features' unchanged exact-SQL unit suites, the
dashboard integration suite and the rules list suite on both providers.
2026-09-08 15:44:38 +05:30
nikhilmantri0902
8e19b17855 test(sqlrulestore): pin deep-nesting compile cases before visitor extraction
Four hand-derived cases covering mixed predicate kinds, parenthesized
OR groups, NOT over a group and three-level nesting with free text and
a timestamp, matching the depth of dashboards' ComplexExamples suite.
2026-09-08 15:06:11 +05:30
nikhilmantri0902
8885497e14 chore: further cleanup 2026-09-08 12:31:33 +05:30
nikhilmantri0902
626e047485 refactor: trim comments to constraint-only per repo comment rules
Rationale and restatements move out of source; dialect reasoning for
LIKE ESCAPE and ILIKE lowering is documented once in the PR body.
2026-09-08 12:13:16 +05:30
nikhilmantri0902
cc3d86c3ec fix(rules): take the read lock in TriggeredAlerts
The lock has been commented out since the method landed, leaving the
rules map read unguarded against concurrent manager writes. Same class
of race this branch fixed in ListRuleStates and GetRule.
2026-09-08 11:54:05 +05:30
nikhilmantri0902
0100083284 fix(sqlstore): drop unusable quote escape from sqlite JSON map paths
sqlite JSON paths have no backslash escapes, so escaping a double quote
in the key produced a path sqlite cannot parse. The character is also
unreachable: the filter grammar's KEY token cannot contain a quote.
Keep the backslash escape, which matches sqlite's raw key comparison.
2026-09-08 11:44:05 +05:30
Nikhil Mantri
648e945471 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-08 11:07:26 +05:30
nikhilmantri0902
7323d5ae0b fix(ruletypes): make list sort deterministic on ties
Ties on the primary sort key kept arbitrary DB order, so rows could
shuffle between page requests causing overlaps or misses. Break ties on
name (case-insensitive) then id, always ascending, with the requested
order applied to the primary key only. Pin the tiebreak in unit tests
and paginate over state ties in the integration suite.
2026-09-08 01:16:39 +05:30
nikhilmantri0902
578b9172ba test(integration): cover the rules list v3 API on both sqlstore providers
Seeds five rules spanning every filter axis and pins the envelope, slim rows,
DSL filters (incl. dotted label keys and missing-label-as-empty semantics),
states param, sort ranks, pagination totals and the error contract; adds
delete_all_rules and an idempotent ensure_notification_channel fixture fn.
Verified against both sqlite and postgres providers.
2026-09-07 20:20:54 +05:30
nikhilmantri0902
2110006d17 feat(sqlrulestore): treat a missing label as empty string for all label value operators
COALESCE applies uniformly instead of only on negations, so labels.key = ''
also matches label-less rules; EXISTS/NOT EXISTS remain the presence checks.
2026-09-07 17:39:41 +05:30
nikhilmantri0902
01dda19d17 feat(sqlrulestore): align label filter semantics with the querier
severity takes the labels operator set (it is an alias for labels.severity,
so EXISTS/NOT EXISTS now work on it), and negative label operators evaluate a
missing label as the empty string instead of always matching, mirroring the
querier's AddDefaultExistsFilter map-attribute semantics.
2026-09-07 17:27:07 +05:30
nikhilmantri0902
b519574b89 feat(ruler): add GET /api/v3/rules route and regenerate API clients
Registers ListRulesV3 with the list params and envelope, marks the v2
ListRules operation deprecated, and regenerates the OpenAPI spec and the
frontend client.
2026-09-07 14:54:46 +05:30
nikhilmantri0902
629ecabce7 feat(ruler): list rules with SQL filter pushdown, state overlay and in-memory pagination
DSL query compiles into the store's WHERE; state is overlaid from a snapshot
of the rule manager map taken under RLock, then the states filter, total,
sort (state/severity rank comparators) and offset/limit run in code so total
always matches what is pageable. StorableRule gains alias:rule to match the
compiler's column references. Also guards the previously unlocked m.rules
reads in ListRuleStates and GetRule.
2026-09-07 14:43:40 +05:30
nikhilmantri0902
131e302c55 feat(sqlrulestore): compile rule list filter DSL to SQL
Adds JSONExtractMapValue to SQLFormatter so a labels map key is one path
segment (dotted label keys work on both dialects), and a visitor over the
shared filterquery grammar mapping rule list DSL keys to SQL.
2026-09-07 13:58:30 +05:30
nikhilmantri0902
239c92ba67 feat(ruletypes): add list params, filter allow-lists and listable rule types for rules list API 2026-09-07 12:48:49 +05:30
35 changed files with 4296 additions and 16 deletions

View File

@@ -8616,6 +8616,98 @@ 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
RuletypesListableRuleViews:
properties:
views:
items:
$ref: '#/components/schemas/RuletypesRuleView'
type: array
required:
- views
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
@@ -8690,6 +8782,16 @@ components:
- ruleType
- condition
type: object
RuletypesPostableRuleView:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
name:
type: string
required:
- name
- data
type: object
RuletypesQueryType:
enum:
- builder
@@ -8828,6 +8930,45 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesRuleView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
RuletypesRuleViewData:
properties:
order:
$ref: '#/components/schemas/RuletypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/RuletypesListSort'
states:
items:
type: string
type: array
version:
type: string
required:
- version
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -20314,11 +20455,237 @@ paths:
summary: Get users by role id
tags:
- users
/api/v2/rules:
/api/v2/rule_views:
get:
deprecated: false
description: This endpoint lists all alert rules with their current evaluation
state
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListRuleViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRuleViews'
status:
type: string
required:
- status
- data
type: object
description: OK
"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 rule saved views
tags:
- rules
post:
deprecated: false
description: Persists the calling user's rule listing state (query, states,
sort, order) as a named, reusable view shared across the org.
operationId: CreateRuleView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: Created
"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: Create rule saved view
tags:
- rules
/api/v2/rule_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete rule saved view
tags:
- rules
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update rule saved view
tags:
- rules
/api/v2/rules:
get:
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":
@@ -26100,6 +26467,90 @@ 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`). 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,39 @@ func TestJSONExtractString(t *testing.T) {
}
}
func TestJSONExtractMapValue(t *testing.T) {
tests := []struct {
name string
column string
mapField string
key string
expected string
}{
{
name: "plain key",
column: "data",
mapField: "labels",
key: "team",
expected: `"data"::jsonb->'labels'->>'team'`,
},
{
name: "dotted key stays one map entry",
column: "data",
mapField: "labels",
key: "k8s.cluster",
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
},
}
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

@@ -19,7 +19,9 @@ import type {
import type {
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
DeleteRuleViewPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
@@ -40,20 +42,372 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
PatchRuleByID200,
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleViewDTO,
TestRule200,
UpdateRuleByIDPathParameters,
UpdateRuleView200,
UpdateRuleViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint lists all alert rules with their current evaluation state
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List rule saved views
*/
export const listRuleViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRuleViews200>({
url: `/api/v2/rule_views`,
method: 'GET',
signal,
});
};
export const getListRuleViewsQueryKey = () => {
return [`/api/v2/rule_views`] as const;
};
export const getListRuleViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
signal,
}) => listRuleViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRuleViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRuleViews>>
>;
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List rule saved views
*/
export function useListRuleViews<
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRuleViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List rule saved views
*/
export const invalidateListRuleViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRuleViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
* @summary Create rule saved view
*/
export const createRuleView = (
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRuleView201>({
url: `/api/v2/rule_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getCreateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
const mutationKey = ['createRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRuleView>>,
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createRuleView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createRuleView>>
>;
export type CreateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create rule saved view
*/
export const useCreateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
return useMutation(getCreateRuleViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete rule saved view
*/
export const deleteRuleView = (
{ id }: DeleteRuleViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rule_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRuleView>>,
{ pathParams: DeleteRuleViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleView>>
>;
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete rule saved view
*/
export const useDeleteRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
return useMutation(getDeleteRuleViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update rule saved view
*/
export const updateRuleView = (
{ id }: UpdateRuleViewPathParameters,
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateRuleView200>({
url: `/api/v2/rule_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getUpdateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRuleView>>,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleView>>
>;
export type UpdateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update rule saved view
*/
export const useUpdateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* 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) => {
@@ -95,6 +449,7 @@ export type ListRulesQueryResult = NonNullable<
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List alert rules
*/
@@ -114,6 +469,7 @@ export function useListRules<
}
/**
* @deprecated
* @summary List alert rules
*/
export const invalidateListRules = async (
@@ -1368,3 +1724,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`). 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 { ...query, queryKey: 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

@@ -9896,6 +9896,149 @@ 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 RuletypesRuleViewDataDTO {
order?: RuletypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: RuletypesListSortDTO;
/**
* @type array
*/
states?: string[];
/**
* @type string
*/
version: string;
}
export interface RuletypesRuleViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface RuletypesListableRuleViewsDTO {
/**
* @type array
*/
views: RuletypesRuleViewDTO[];
}
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
@@ -9992,11 +10135,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
@@ -10049,6 +10187,14 @@ export interface RuletypesPostableRuleDTO {
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
name: string;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
@@ -13269,6 +13415,36 @@ export type GetUsersByRoleID200 = {
status: string;
};
export type ListRuleViews200 = {
data: RuletypesListableRuleViewsDTO;
/**
* @type string
*/
status: string;
};
export type CreateRuleView201 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteRuleViewPathParameters = {
id: string;
};
export type UpdateRuleViewPathParameters = {
id: string;
};
export type UpdateRuleView200 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type ListRules200 = {
/**
* @type array
@@ -13830,6 +14006,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

@@ -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`). 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
@@ -116,6 +132,64 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},

View File

@@ -718,8 +718,8 @@ func (m *Manager) Rules() []Rule {
// TriggeredAlerts returns the list of the manager's rules.
func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
// m.mtx.RLock()
// defer m.mtx.RUnlock()
m.mtx.RLock()
defer m.mtx.RUnlock()
namedAlerts := []*ruletypes.NamedAlert{}
@@ -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,84 @@ 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) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
states, err := params.AlertStates()
if err != nil {
return nil, err
}
stateFilter := make(map[ruletypes.AlertState]struct{}, len(states))
for _, state := range states {
stateFilter[state] = struct{}{}
}
storedRules, err := m.ruleStore.GetStoredRulesMatching(ctx, claims.OrgID, params.Query)
if err != nil {
return nil, err
}
stateByRuleID := m.snapshotRuleStates()
listableRules := make([]*ruletypes.ListableRule, 0, len(storedRules))
for _, s := range storedRules {
gettable := ruletypes.GettableRule{}
if err := json.Unmarshal([]byte(s.Data), &gettable); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", s.ID.StringValue()), errors.Attr(err))
continue
}
gettable.Id = s.ID.StringValue()
if state, ok := stateByRuleID[gettable.Id]; ok {
gettable.State = state
} else {
gettable.State = ruletypes.StateDisabled
gettable.Disabled = true
}
if len(stateFilter) > 0 {
if _, ok := stateFilter[gettable.State]; !ok {
continue
}
}
gettable.CreatedAt = s.CreatedAt
gettable.CreatedBy = &s.CreatedBy
gettable.UpdatedAt = s.UpdatedAt
gettable.UpdatedBy = &s.UpdatedBy
listableRules = append(listableRules, ruletypes.NewListableRule(&gettable))
}
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 {
@@ -899,7 +979,10 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
}
r.Id = id.StringValue()
// fetch state of rule from memory
if rm, ok := m.rules[r.Id]; !ok {
m.mtx.RLock()
rm, ok := m.rules[r.Id]
m.mtx.RUnlock()
if !ok {
r.State = ruletypes.StateDisabled
r.Disabled = true
} else {

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)
@@ -11,6 +12,11 @@ type Handler interface {
PatchRuleByID(http.ResponseWriter, *http.Request)
TestRule(http.ResponseWriter, *http.Request)
ListRuleViews(http.ResponseWriter, *http.Request)
CreateRuleView(http.ResponseWriter, *http.Request)
UpdateRuleView(http.ResponseWriter, *http.Request)
DeleteRuleView(http.ResponseWriter, *http.Request)
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
CreateDowntimeSchedule(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)
@@ -46,4 +49,9 @@ type Ruler interface {
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
// store interface. The handler should not call store methods directly.
MaintenanceStore() alertmanagertypes.MaintenanceStore
CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error)
ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error)
UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error)
DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) 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, query string) ([]*ruletypes.StorableRule, error) {
return m.ruleStore.GetStoredRulesMatching(ctx, orgID, query)
}
// 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

@@ -0,0 +1,20 @@
package sqlrulestore
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 Compile(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,168 @@
package sqlrulestore
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; label keys are case-sensitive and unknown keys are rejected.
type ruleFieldResolver struct{}
func (r ruleFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
key := strings.ToLower(rawKey)
if allowedOperations, isReserved := ruletypes.ReservedOps[ruletypes.DSLKey(key)]; isReserved {
return r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key), allowedOperations)
}
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)
}
v.AddError("unknown filter key %q, use one of the reserved keys or labels.<key>", rawKey)
return ""
}
func (r ruleFieldResolver) resolveReservedKey(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
if _, allowed := allowedOperations[operation]; !allowed {
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
return ""
}
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,407 @@
package sqlrulestore
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 := Compile(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: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
{subtestName: "whitespace query yields nil", dslQueryToCompile: " ", emptyQueryExpected: true},
})
}
func TestCompileName(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name equals",
dslQueryToCompile: "name = 'payment latency'",
expectedSQL: `json_extract("rule"."data", '$.alert') = ?`,
expectedArgs: []any{"payment latency"},
},
{
subtestName: "name contains escapes wildcards",
dslQueryToCompile: "name CONTAINS '50%'",
expectedSQL: `json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\'`,
expectedArgs: []any{`%50\%%`},
},
{
subtestName: "name ilike",
dslQueryToCompile: "name ILIKE 'Prod%'",
expectedSQL: `lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\'`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "name in list",
dslQueryToCompile: "name IN ['a', 'b']",
expectedSQL: `json_extract("rule"."data", '$.alert') IN (?, ?)`,
expectedArgs: []any{"a", "b"},
},
{
subtestName: "range operator rejected on name",
dslQueryToCompile: "name > 'x'",
expectedErrShouldContain: `operator > is not allowed for key "name"`,
},
{
subtestName: "regexp rejected on name",
dslQueryToCompile: "name REGEXP 'x.*'",
expectedErrShouldContain: `operator REGEXP is not allowed for key "name"`,
},
})
}
func TestCompileSeverityAndLabels(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "severity equals targets labels map",
dslQueryToCompile: "severity = 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "severity negation treats a missing label as empty string",
dslQueryToCompile: "severity != 'critical'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{"critical"},
},
{
subtestName: "severity != empty string excludes rules without severity",
dslQueryToCompile: "severity != ''",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
expectedArgs: []any{""},
},
{
subtestName: "severity exists through the alias",
dslQueryToCompile: "severity EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NOT NULL`,
},
{
subtestName: "severity not exists through the alias",
dslQueryToCompile: "severity NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NULL`,
},
{
subtestName: "label equals",
dslQueryToCompile: "labels.team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "dotted label key is one map entry",
dslQueryToCompile: "labels.k8s.cluster = 'prod-1'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."k8s.cluster"'), '') = ?`,
expectedArgs: []any{"prod-1"},
},
{
subtestName: "label key keeps its case",
dslQueryToCompile: "labels.Team = 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') = ?`,
expectedArgs: []any{"infra"},
},
{
subtestName: "label exists",
dslQueryToCompile: "labels.team EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NOT NULL`,
},
{
subtestName: "label not exists",
dslQueryToCompile: "labels.team NOT EXISTS",
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NULL`,
},
{
subtestName: "label not contains includes label-less rules",
dslQueryToCompile: "labels.team NOT CONTAINS 'infra'",
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT LIKE ? ESCAPE '\'`,
expectedArgs: []any{"%infra%"},
},
{
subtestName: "label not in includes label-less rules",
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: "alert_type equals",
dslQueryToCompile: "alert_type = 'LOGS_BASED_ALERT'",
expectedSQL: `json_extract("rule"."data", '$.alertType') = ?`,
expectedArgs: []any{"LOGS_BASED_ALERT"},
},
{
subtestName: "rule_type in list",
dslQueryToCompile: "rule_type IN ['threshold_rule', 'promql_rule']",
expectedSQL: `json_extract("rule"."data", '$.ruleType') IN (?, ?)`,
expectedArgs: []any{"threshold_rule", "promql_rule"},
},
{
subtestName: "invalid alert_type value rejected",
dslQueryToCompile: "alert_type = 'bogus'",
expectedErrShouldContain: `invalid value "bogus" for "alert_type"`,
},
{
subtestName: "contains rejected on rule_type",
dslQueryToCompile: "rule_type CONTAINS 'thresh'",
expectedErrShouldContain: `operator CONTAINS is not allowed for key "rule_type"`,
},
})
}
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: "created_by equals",
dslQueryToCompile: "created_by = 'nikhil@signoz.io'",
expectedSQL: `rule.created_by = ?`,
expectedArgs: []any{"nikhil@signoz.io"},
},
{
subtestName: "created_at range",
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
expectedSQL: `rule.created_at >= ?`,
expectedArgs: []any{createdAt},
},
{
subtestName: "updated_at between",
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: "non-timestamp rejected on created_at",
dslQueryToCompile: "created_at >= 'yesterday'",
expectedErrShouldContain: "invalid RFC3339 timestamp",
},
})
}
func TestCompileFreeText(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "bare word searches name, description and labels",
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: "and of label and column",
dslQueryToCompile: "labels.team = 'infra' AND created_by = 'x'",
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? AND rule.created_by = ?)`,
expectedArgs: []any{"infra", "x"},
},
{
subtestName: "not wraps the inner predicate",
dslQueryToCompile: "NOT (name = 'x')",
expectedSQL: `NOT (json_extract("rule"."data", '$.alert') = ?)`,
expectedArgs: []any{"x"},
},
{
subtestName: "or of name and severity",
dslQueryToCompile: "name CONTAINS 'pay' OR severity = 'critical'",
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?)`,
expectedArgs: []any{"%pay%", "critical"},
},
})
}
func TestCompileComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + label = + severity IN + created_by !=",
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 '\' ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
`AND COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') IN (?, ?) ` +
`AND rule.created_by <> ?)`,
expectedArgs: []any{"%latency%", "payments", "critical", "error", "ops@signoz.io"},
},
{
subtestName: "nested OR / AND with parens",
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 '\') ` +
`AND (COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
`OR json_extract("rule"."data", '$.labels."team"') IS NOT NULL))`,
expectedArgs: []any{"prod", "staging", "%prod%", "critical"},
},
{
subtestName: "NOT over a group ANDed with an enum",
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 '\')) ` +
`AND json_extract("rule"."data", '$.alertType') = ?)`,
expectedArgs: []any{"infra", "%cpu%", "METRIC_BASED_ALERT"},
},
{
subtestName: "free text with three-level nesting and a 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 (COALESCE(json_extract("rule"."data", '$.labels."team"'), '') <> ? AND rule.updated_at > ?)))`,
expectedArgs: []any{"%prod%", "%prod%", "%prod%", "%pay%", "infra",
time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
},
})
}
func TestCompileErrors(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "unknown key rejected instead of matching nothing",
dslQueryToCompile: "team = 'infra'",
expectedErrShouldContain: `unknown filter key "team"`,
},
{
subtestName: "state is not a DSL key",
dslQueryToCompile: "state = 'firing'",
expectedErrShouldContain: `unknown filter key "state"`,
},
{
subtestName: "syntax error surfaces position",
dslQueryToCompile: "created_by ==== (((",
expectedErrShouldContain: "syntax error",
},
{
subtestName: "like pattern with dangling escape rejected",
dslQueryToCompile: `name LIKE 'prod\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "ilike pattern with dangling escape rejected",
dslQueryToCompile: `name ILIKE '%\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
{
subtestName: "label like pattern with dangling escape rejected",
dslQueryToCompile: `labels.team NOT LIKE 'infra\\'`,
expectedErrShouldContain: "must not end with an unescaped backslash",
},
})
}
func TestCompileTrailingLiteralBackslash(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "escaped trailing backslash compiles",
dslQueryToCompile: `name LIKE '%\\\\'`,
expectedSQL: `json_extract("rule"."data", '$.alert') 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 := Compile(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

@@ -3,6 +3,7 @@ package sqlrulestore
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
@@ -89,6 +90,46 @@ func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return nil
}
func (r *rule) GetStoredRulesMatching(ctx context.Context, orgID string, query string) ([]*ruletypes.StorableRule, error) {
compiled, err := Compile(query, r.sqlstore.Formatter())
if err != nil {
return nil, err
}
rules := make([]*ruletypes.StorableRule, 0)
q := r.sqlstore.
BunDB().
NewSelect().
Model(&rules).
Where("org_id = ?", orgID)
if !compiled.IsEmpty() {
q = q.Where(compiled.SQL, compiled.Args...)
}
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

@@ -0,0 +1,93 @@
package sqlrulestore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (r *rule) CreateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
_, err := r.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(view).
Exec(ctx)
if err != nil {
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "rule view with id %s already exists", view.ID)
}
return nil
}
func (r *rule) GetRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.RuleView, error) {
view := new(ruletypes.RuleView)
err := r.sqlstore.
BunDB().
NewSelect().
Model(view).
Where("id = ?", id).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
}
return view, nil
}
func (r *rule) ListRuleViews(ctx context.Context, orgID valuer.UUID) ([]*ruletypes.RuleView, error) {
views := make([]*ruletypes.RuleView, 0)
err := r.sqlstore.
BunDB().
NewSelect().
Model(&views).
Where("org_id = ?", orgID).
OrderExpr("updated_at DESC").
Scan(ctx)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list rule views")
}
return views, nil
}
func (r *rule) UpdateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
res, err := r.sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(view).
WherePK().
Where("org_id = ?", view.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't update rule view")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view update result")
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", view.ID)
}
return nil
}
func (r *rule) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
res, err := r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(ruletypes.RuleView)).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete rule view")
}
rows, err := res.RowsAffected()
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view delete result")
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
}
return nil
}

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()
@@ -322,3 +345,122 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) ListRuleViews(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
views, err := handler.ruler.ListRuleViews(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, views)
}
func (handler *handler) CreateRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
var postable ruletypes.PostableRuleView
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
render.Error(rw, err)
return
}
view, err := handler.ruler.CreateRuleView(ctx, orgID, postable)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, view)
}
func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
var updatable ruletypes.UpdatableRuleView
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
render.Error(rw, err)
return
}
view, err := handler.ruler.UpdateRuleView(ctx, orgID, id, updatable)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
if err := handler.ruler.DeleteRuleView(ctx, orgID, id); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

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)
}
@@ -143,3 +147,41 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
return provider.manager.MaintenanceStore()
}
func (provider *provider) CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
view := postable.NewRuleView(orgID)
if err := provider.ruleStore.CreateRuleView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (provider *provider) ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error) {
views, err := provider.ruleStore.ListRuleViews(ctx, orgID)
if err != nil {
return nil, err
}
return &ruletypes.ListableRuleViews{Views: views}, nil
}
func (provider *provider) UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
view, err := provider.ruleStore.GetRuleView(ctx, orgID, id)
if err != nil {
return nil, err
}
view.Update(updatable)
if err := provider.ruleStore.UpdateRuleView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (provider *provider) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return provider.ruleStore.DeleteRuleView(ctx, orgID, id)
}

View File

@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
sqlmigration.NewAddRuleViewFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,78 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addRuleView struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddRuleViewFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_rule_view"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRuleView{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addRuleView) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRuleView) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "rule_view",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "data", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if _, err := tx.NewCreateIndex().
Table("rule_view").
Column("org_id").
Index("idx_rule_view_org_id").
IfNotExists().
Exec(ctx); err != nil {
return err
}
return tx.Commit()
}
func (migration *addRuleView) Down(_ context.Context, _ *bun.DB) error {
return nil
}

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

@@ -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("...)

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

@@ -0,0 +1,147 @@
package ruletypes
import (
"slices"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
DefaultListLimit = 20
MaxListLimit = 5000
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 })
}
// ListFilter is the rule listing state shared by the v3 list params and saved views.
type ListFilter struct {
Query string `query:"query" json:"query"`
// gin cannot bind a slice of valuer enums; AlertStates converts these.
States []string `query:"states" json:"states" nullable:"false"`
Sort ListSort `query:"sort" json:"sort"`
Order ListOrder `query:"order" json:"order"`
}
// Validate normalizes in place; zero sort/order get the defaults, nil states an empty slice.
func (f *ListFilter) Validate() error {
if f.States == nil {
f.States = []string{}
}
if n := utf8.RuneCountInString(f.Query); n > MaxListQueryLen {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"query cannot be longer than %d characters, got %d", MaxListQueryLen, n)
}
if _, err := f.AlertStates(); err != nil {
return err
}
if f.Sort.IsZero() {
f.Sort = ListSortUpdatedAt
} else if !f.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid sort %q, expected one of: `updated_at`, `created_at`, `name`, `state`, `severity`", f.Sort)
}
if f.Order.IsZero() {
f.Order = ListOrderDesc
} else if !f.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeRuleListInvalid,
"invalid order %q, expected `asc` or `desc`", f.Order)
}
return nil
}
// AlertStates parses States; empty means no state filtering.
func (f *ListFilter) AlertStates() ([]AlertState, error) {
if len(f.States) == 0 {
return nil, nil
}
states := make([]AlertState, 0, len(f.States))
for _, raw := range f.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
}
type ListRulesParams struct {
ListFilter
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 err := p.ListFilter.Validate(); err != nil {
return err
}
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)
}
return 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: "empty params get defaults",
params: ListRulesParams{},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: DefaultListLimit,
},
{
name: "explicit values kept",
params: ListRulesParams{ListFilter: ListFilter{Sort: ListSortSeverity, Order: ListOrderAsc}, Limit: 50, Offset: 100},
wantSort: ListSortSeverity,
wantOrder: ListOrderAsc,
wantLimit: 50,
},
{
name: "over-max limit clamped",
params: ListRulesParams{Limit: MaxListLimit + 1},
wantSort: ListSortUpdatedAt,
wantOrder: ListOrderDesc,
wantLimit: MaxListLimit,
},
{
name: "invalid state rejected",
params: ListRulesParams{ListFilter: ListFilter{States: []string{"bogus"}}},
wantErr: `invalid state "bogus"`,
},
{
name: "invalid sort rejected",
params: ListRulesParams{ListFilter: ListFilter{Sort: ListSort{valuer.NewString("bogus")}}},
wantErr: "invalid sort",
},
{
name: "invalid order rejected",
params: ListRulesParams{ListFilter: ListFilter{Order: ListOrder{valuer.NewString("bogus")}}},
wantErr: "invalid order",
},
{
name: "negative limit rejected",
params: ListRulesParams{Limit: -1},
wantErr: "invalid limit",
},
{
name: "negative offset rejected",
params: ListRulesParams{Offset: -1},
wantErr: "invalid offset",
},
{
name: "over-long query rejected",
params: ListRulesParams{ListFilter: ListFilter{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: "valid states parsed to typed values",
states: []string{"firing", "pending"},
wantStates: []AlertState{StateFiring, StatePending},
},
{
name: "absent states mean no filtering",
states: nil,
},
{
name: "invalid state rejected",
states: []string{"bogus"},
wantErr: `invalid state "bogus"`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
params := ListRulesParams{ListFilter: ListFilter{States: tc.states}}
states, err := params.AlertStates()
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,162 @@
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
}
func NewListableRule(rule *GettableRule) *ListableRule {
listable := &ListableRule{
Id: rule.Id,
State: rule.State,
AlertName: rule.AlertName,
Description: rule.Description,
AlertType: rule.AlertType,
RuleType: rule.RuleType,
Disabled: rule.Disabled,
Labels: rule.Labels,
TimeAuditable: types.TimeAuditable{
CreatedAt: rule.CreatedAt,
UpdatedAt: rule.UpdatedAt,
},
}
if rule.CreatedBy != nil {
listable.CreatedBy = *rule.CreatedBy
}
if rule.UpdatedBy != nil {
listable.UpdatedBy = *rule.UpdatedBy
}
return listable
}
// 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(),
}
}
// Display priority, worst first; NOT AlertState.Severity(), which ranks disabled/nodata above firing.
var stateDisplayRank = map[AlertState]int{
StateFiring: 5,
StatePending: 4,
StateRecovering: 3,
StateNoData: 2,
StateInactive: 1,
StateDisabled: 0,
}
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(stateDisplayRank[a.State], stateDisplayRank[b.State])
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,163 @@
package ruletypes
import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/stretchr/testify/assert"
)
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 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: "state desc is display priority firing first",
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", "pending", "recovering", "nodata", "inactive", "disabled"},
},
{
name: "severity desc ranks known values then custom ones lexically",
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: "name asc is case-insensitive",
rules: []*ListableRule{
listableRule("banana", StateInactive, "", base),
listableRule("Apple", StateInactive, "", base),
listableRule("cherry", StateInactive, "", base),
},
sortBy: ListSortName,
order: ListOrderAsc,
wantNames: []string{"Apple", "banana", "cherry"},
},
{
name: "updated_at desc puts newest first",
rules: []*ListableRule{
listableRule("old", StateInactive, "", base),
listableRule("new", StateInactive, "", base.Add(time.Hour)),
},
sortBy: ListSortUpdatedAt,
order: ListOrderDesc,
wantNames: []string{"new", "old"},
},
{
name: "state desc ties break on name asc",
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: "state asc flips buckets but tiebreak stays name asc",
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,16 @@ 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 list filter query; an empty query matches all.
GetStoredRulesMatching(context.Context, string, string) ([]*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)
CreateRuleView(context.Context, *RuleView) error
GetRuleView(context.Context, valuer.UUID, valuer.UUID) (*RuleView, error)
ListRuleViews(context.Context, valuer.UUID) ([]*RuleView, error)
UpdateRuleView(context.Context, *RuleView) error
DeleteRuleView(context.Context, valuer.UUID, valuer.UUID) error
}

View File

@@ -0,0 +1,110 @@
package ruletypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const (
RuleViewSchemaVersion = "v1"
MaxRuleViewNameLen = 64
)
var (
ErrCodeRuleViewInvalidInput = errors.MustNewCode("rule_view_invalid_input")
ErrCodeRuleViewNotFound = errors.MustNewCode("rule_view_not_found")
)
type RuleView struct {
bun.BaseModel `bun:"table:rule_view,alias:rule_view"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
Data RuleViewData `bun:"data,type:text,notnull" json:"data" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
}
// RuleViewData holds the rule listing state (ListRulesParams minus pagination) a view replays.
type RuleViewData struct {
Version string `json:"version" required:"true"`
ListFilter
}
func (d *RuleViewData) Validate() error {
if d.Version != RuleViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"version must be %q, got %q", RuleViewSchemaVersion, d.Version)
}
return d.ListFilter.Validate()
}
type PostableRuleView struct {
Name string `json:"name" required:"true"`
Data RuleViewData `json:"data" required:"true"`
}
func (p *PostableRuleView) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableRuleView
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeRuleViewInvalidInput, "invalid saved view request body").WithAdditional(err.Error())
}
*p = PostableRuleView(tmp)
return p.Validate()
}
func (p *PostableRuleView) Validate() error {
if err := validateRuleViewName(p.Name); err != nil {
return err
}
return p.Data.Validate()
}
func (p PostableRuleView) NewRuleView(orgID valuer.UUID) *RuleView {
now := time.Now()
return &RuleView{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
Name: p.Name,
Data: p.Data,
}
}
type UpdatableRuleView = PostableRuleView
func (v *RuleView) Update(updateable UpdatableRuleView) {
v.Name = updateable.Name
v.Data = updateable.Data
v.UpdatedAt = time.Now()
}
type ListableRuleViews struct {
Views []*RuleView `json:"views" required:"true" nullable:"false"`
}
func validateRuleViewName(name string) error {
if strings.TrimSpace(name) == "" {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name is required")
}
if name != strings.TrimSpace(name) {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput, "name must not have leading or trailing whitespace")
}
if n := utf8.RuneCountInString(name); n > MaxRuleViewNameLen {
return errors.NewInvalidInputf(ErrCodeRuleViewInvalidInput,
"name must be at most %d characters, got %d", MaxRuleViewNameLen, n)
}
return nil
}

View File

@@ -0,0 +1,184 @@
package ruletypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRuleViewDataValidate(t *testing.T) {
cases := []struct {
description string
data RuleViewData
expectError bool
}{
{
description: "valid with all fields set",
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Query: "name CONTAINS 'prod'", States: []string{"firing", "pending"}, Sort: ListSortName, Order: ListOrderAsc}},
expectError: false,
},
{
description: "valid with zero states, sort and order",
data: RuleViewData{Version: RuleViewSchemaVersion},
expectError: false,
},
{
description: "query over the cap is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Query: strings.Repeat("x", MaxListQueryLen+1)}},
expectError: true,
},
{
description: "wrong version is rejected",
data: RuleViewData{Version: "v2"},
expectError: true,
},
{
description: "empty version is rejected",
data: RuleViewData{},
expectError: true,
},
{
description: "unknown state is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"exploding"}}},
expectError: true,
},
{
description: "unknown sort is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Sort: ListSort{valuer.NewString("bogus")}}},
expectError: true,
},
{
description: "unknown order is rejected",
data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Order: ListOrder{valuer.NewString("sideways")}}},
expectError: true,
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
err := c.data.Validate()
if c.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestRuleViewDataValidateDefaults(t *testing.T) {
data := RuleViewData{Version: RuleViewSchemaVersion}
require.NoError(t, data.Validate())
assert.Equal(t, ListSortUpdatedAt, data.Sort)
assert.Equal(t, ListOrderDesc, data.Order)
assert.Equal(t, []string{}, data.States)
}
func TestPostableRuleViewUnmarshalJSON(t *testing.T) {
cases := []struct {
description string
body string
expectError bool
expectedErrMsg string
expectedName string
}{
{
description: "valid body keeps name as-is",
body: `{"name":"my view","data":{"version":"v1","query":"severity = 'critical'","states":["firing"],"sort":"name","order":"asc"}}`,
expectError: false,
expectedName: "my view",
},
{
description: "name with surrounding whitespace is rejected",
body: `{"name":" my view ","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name must not have leading or trailing whitespace",
},
{
description: "unknown field is rejected",
body: `{"name":"my view","data":{"version":"v1"},"extra":true}`,
expectError: true,
},
{
description: "blank name is rejected",
body: `{"name":" ","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name is required",
},
{
description: "name over max length is rejected",
body: `{"name":"` + strings.Repeat("x", MaxRuleViewNameLen+1) + `","data":{"version":"v1"}}`,
expectError: true,
expectedErrMsg: "name must be at most",
},
{
description: "invalid data version is rejected",
body: `{"name":"my view","data":{"version":"v9"}}`,
expectError: true,
},
{
description: "invalid state is rejected",
body: `{"name":"my view","data":{"version":"v1","states":["exploding"]}}`,
expectError: true,
expectedErrMsg: "invalid state",
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
var p PostableRuleView
err := json.Unmarshal([]byte(c.body), &p)
if c.expectError {
assert.Error(t, err)
if c.expectedErrMsg != "" {
assert.ErrorContains(t, err, c.expectedErrMsg)
}
return
}
require.NoError(t, err)
assert.Equal(t, c.expectedName, p.Name)
})
}
}
func TestPostableRuleViewNewRuleView(t *testing.T) {
orgID := valuer.GenerateUUID()
postable := PostableRuleView{
Name: "my view",
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"firing"}, Sort: ListSortName, Order: ListOrderAsc}},
}
view := postable.NewRuleView(orgID)
assert.Equal(t, orgID, view.OrgID)
assert.Equal(t, "my view", view.Name)
assert.Equal(t, postable.Data, view.Data)
assert.False(t, view.ID.IsZero())
assert.False(t, view.CreatedAt.IsZero())
assert.Equal(t, view.CreatedAt, view.UpdatedAt)
}
func TestRuleViewUpdate(t *testing.T) {
orgID := valuer.GenerateUUID()
view := PostableRuleView{
Name: "original",
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{Sort: ListSortName, Order: ListOrderAsc}},
}.NewRuleView(orgID)
createdAt := view.CreatedAt
view.Update(UpdatableRuleView{
Name: "renamed",
Data: RuleViewData{Version: RuleViewSchemaVersion, ListFilter: ListFilter{States: []string{"disabled"}, Sort: ListSortCreatedAt, Order: ListOrderDesc}},
})
assert.Equal(t, "renamed", view.Name)
assert.Equal(t, []string{"disabled"}, view.Data.States)
assert.Equal(t, ListSortCreatedAt, view.Data.Sort)
assert.Equal(t, ListOrderDesc, view.Data.Order)
assert.Equal(t, createdAt, view.CreatedAt)
assert.True(t, view.UpdatedAt.After(createdAt))
}

View File

@@ -19,6 +19,7 @@ from fixtures.logger import setup_logger
from fixtures.logs import Logs
from fixtures.maildev import get_all_mails, verify_email_received
from fixtures.metrics import Metrics
from fixtures.notification_channel import ensure_notification_channel
from fixtures.traces import Traces
logger = setup_logger(__name__)
@@ -88,6 +89,70 @@ 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],
create_alert_rule: Callable[[dict], str],
) -> Callable[[dict, list[dict]], None]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Cleanup is owned by create_alert_rule, which deletes the rules it created.
def _seed_alert_rules(channel_config: dict, rules: list[dict]) -> None:
delete_all_rules(signoz, admin_token)
ensure_notification_channel(signoz, admin_token, channel_config)
for rule in rules:
create_alert_rule(rule)
return _seed_alert_rules
@pytest.fixture(name="create_rule_view", scope="function")
def create_rule_view(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> Callable[[dict], dict]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
view_ids = []
def _create_rule_view(view: dict) -> dict:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/rule_views"),
json=view,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"Failed to create rule view, api returned {response.status_code} with response: {response.text}"
created = response.json()["data"]
view_ids.append(created["id"])
return created
yield _create_rule_view
# A view the test already deleted returns 404; only real failures are logged.
for view_id in view_ids:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/rule_views/{view_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code not in (HTTPStatus.NO_CONTENT, HTTPStatus.NOT_FOUND):
logger.error("Error deleting rule view: %s", {"view_id": view_id, "response": response.text})
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

@@ -35,6 +35,26 @@ EMAIL_TRANSPORT_KEYS = [
]
def ensure_notification_channel(signoz: types.SigNoz, token: str, channel_config: dict) -> None:
# Deliberately no teardown: the stock channel fixture's teardown is broken, so channels are reused idempotently.
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
if channel_config["name"] in [channel["name"] for channel in response.json()["data"] or []]:
return
create_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json=channel_config,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert create_response.status_code == HTTPStatus.CREATED, f"failed to create channel: {create_response.text}"
def assert_email_channel_payload_clean(payload: str) -> None:
receiver = json.loads(payload)
for email_config in receiver["email_configs"]:

View File

@@ -0,0 +1,542 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v3/rules"
SEED_CHANNEL = {"name": "list-rules-v3-channel", "email_configs": [{"to": "list-rules-v3@integration.test"}]}
EVALUATION = {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}}
NOTIFICATION_SETTINGS = {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
}
METRIC_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "list_rules_v3_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
}
LOGS_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 100, "matchType": "at_least_once", "op": "above", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}],
"filter": {"expression": ""},
},
}
],
},
"selectedQueryName": "A",
}
PROMQL_CONDITION = {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 1, "matchType": "at_least_once", "op": "below", "channels": ["list-rules-v3-channel"]}],
},
"compositeQuery": {
"queryType": "promql",
"panelType": "graph",
"queries": [{"type": "promql", "spec": {"name": "A", "query": '{"list_rules_v3_up"}'}}],
},
"selectedQueryName": "A",
}
SEED_RULES = [
{
"alert": "payment latency high",
"description": "p99 latency guard",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"severity": "critical", "team": "payments", "k8s.cluster": "prod-1"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "payment gateway errors",
"description": "error rate watch",
"alertType": "LOGS_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": LOGS_CONDITION,
"labels": {"severity": "warning", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "checkout conversion drop",
"description": "funnel watcher",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"severity": "important", "team": "checkout"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "infra cpu saturation",
"description": "node headroom",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": METRIC_CONDITION,
"labels": {"team": "infra"},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
{
"alert": "prom uptime probe",
"description": "blackbox liveness",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "promql_rule",
"condition": PROMQL_CONDITION,
"labels": {},
"annotations": {"summary": "s", "description": "d"},
"evaluation": EVALUATION,
"notificationSettings": NOTIFICATION_SETTINGS,
"version": "v5",
"schemaVersion": "v2alpha1",
},
]
RESERVED_KEYWORDS = [
"alert_type",
"created_at",
"created_by",
"labels.<key>",
"name",
"rule_type",
"severity",
"updated_at",
"updated_by",
]
def test_envelope_and_slim_rows(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["total"] == 5
assert len(data["rules"]) == 5
assert data["reservedKeywords"] == RESERVED_KEYWORDS
label_pairs = [(pair["key"], pair["value"]) for pair in data["labels"]]
assert label_pairs == sorted(label_pairs), "label pairs must be sorted by key then value"
for expected_pair in [
("k8s.cluster", "prod-1"),
("severity", "critical"),
("severity", "important"),
("severity", "warning"),
("team", "checkout"),
("team", "infra"),
("team", "payments"),
]:
assert expected_pair in label_pairs, f"missing label pair {expected_pair}"
by_name = {rule["alert"]: rule for rule in data["rules"]}
assert set(by_name) == {r["alert"] for r in SEED_RULES}
for rule in data["rules"]:
for forbidden_field in ("condition", "annotations", "notificationSettings", "evaluation", "source", "version", "schemaVersion"):
assert forbidden_field not in rule, f"slim row leaked {forbidden_field}"
for required_field in ("id", "state", "alert", "alertType", "ruleType", "createdAt", "updatedAt"):
assert required_field in rule, f"slim row missing {required_field}"
assert rule["createdBy"] == USER_ADMIN_EMAIL
assert rule["updatedBy"] == USER_ADMIN_EMAIL
assert by_name["checkout conversion drop"]["state"] == "disabled"
assert by_name["checkout conversion drop"]["disabled"] is True
assert by_name["payment latency high"]["state"] == "inactive"
assert by_name["payment latency high"]["description"] == "p99 latency guard"
assert by_name["payment latency high"]["labels"] == {"severity": "critical", "team": "payments", "k8s.cluster": "prod-1"}
assert by_name["payment gateway errors"]["alertType"] == "LOGS_BASED_ALERT"
assert by_name["prom uptime probe"]["ruleType"] == "promql_rule"
def test_query_filters(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
cases = [
("name = 'payment latency high'", {"payment latency high"}),
("name CONTAINS 'payment'", {"payment latency high", "payment gateway errors"}),
# free text goes through LOWER() on both dialects, so a case mismatch must still match
("PAYMENT", {"payment latency high", "payment gateway errors"}),
# free text also matches the description field
("blackbox", {"prom uptime probe"}),
(f"created_by = '{USER_ADMIN_EMAIL}'", {r["alert"] for r in SEED_RULES}),
("created_at >= '2020-01-01T00:00:00Z'", {r["alert"] for r in SEED_RULES}),
("created_at < '2020-01-01T00:00:00Z'", set()),
("alert_type = 'LOGS_BASED_ALERT'", {"payment gateway errors"}),
("rule_type = 'promql_rule'", {"prom uptime probe"}),
("rule_type IN ['threshold_rule']", {"payment latency high", "payment gateway errors", "checkout conversion drop", "infra cpu saturation"}),
("labels.team = 'payments'", {"payment latency high", "payment gateway errors"}),
("labels.k8s.cluster = 'prod-1'", {"payment latency high"}),
("labels.team EXISTS", {"payment latency high", "payment gateway errors", "checkout conversion drop", "infra cpu saturation"}),
("labels.team NOT EXISTS", {"prom uptime probe"}),
("NOT (labels.team EXISTS)", {"prom uptime probe"}),
(
"(labels.team = 'payments' OR labels.team = 'infra') AND name NOT CONTAINS 'gateway'",
{"payment latency high", "infra cpu saturation"},
),
]
for query, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"query": query},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"query {query!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"query {query!r}"
assert data["total"] == len(expected_names), f"query {query!r}: total mismatch"
def test_label_missing_semantics(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
# A missing label uniformly evaluates as the empty string for value
# operators; presence is expressed with EXISTS / NOT EXISTS.
cases = [
("severity = ''", {"infra cpu saturation", "prom uptime probe"}),
("severity != ''", {"payment latency high", "payment gateway errors", "checkout conversion drop"}),
("severity != 'critical'", {"payment gateway errors", "checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("severity EXISTS", {"payment latency high", "payment gateway errors", "checkout conversion drop"}),
("severity NOT EXISTS", {"infra cpu saturation", "prom uptime probe"}),
("severity = 'critical'", {"payment latency high"}),
("severity IN ['critical', 'warning']", {"payment latency high", "payment gateway errors"}),
("labels.team != 'payments'", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("labels.team NOT IN ['payments']", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
("labels.team NOT CONTAINS 'pay'", {"checkout conversion drop", "infra cpu saturation", "prom uptime probe"}),
]
for query, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"query": query},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"query {query!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"query {query!r}"
assert data["total"] == len(expected_names), f"query {query!r}: total mismatch"
def test_states_param(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
# No telemetry is seeded, so enabled rules sit at inactive and the one
# disabled rule reads disabled, deterministic without waiting on evals.
cases = [
({"states": ["disabled"]}, {"checkout conversion drop"}),
({"states": ["inactive"]}, {"payment latency high", "payment gateway errors", "infra cpu saturation", "prom uptime probe"}),
({"states": ["inactive", "disabled"]}, {r["alert"] for r in SEED_RULES}),
({"states": ["firing"]}, set()),
({"states": ["disabled"], "query": "labels.team = 'checkout'"}, {"checkout conversion drop"}),
({"states": ["disabled"], "query": "labels.team = 'payments'"}, set()),
]
for params, expected_names in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"params {params!r}: {response.text}"
data = response.json()["data"]
assert {rule["alert"] for rule in data["rules"]} == expected_names, f"params {params!r}"
assert data["total"] == len(expected_names), f"params {params!r}: total mismatch"
def test_sorting(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"checkout conversion drop",
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
]
# state display priority: inactive (rank 1) outranks disabled (rank 0);
# the four inactive rules tie on state and must break on name asc
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "desc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
"checkout conversion drop",
]
# asc flips the state buckets but the name tiebreak stays ascending
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "asc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"checkout conversion drop",
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
]
# severity: known ranks first (critical > warning), then custom values
# lexically, then rules without severity tie and break on name asc
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "severity", "order": "desc"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert [rule["alert"] for rule in response.json()["data"]["rules"]] == [
"payment latency high",
"payment gateway errors",
"checkout conversion drop",
"infra cpu saturation",
"prom uptime probe",
]
for order in ("asc", "desc"):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "created_at", "order": order},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
created_ats = [rule["createdAt"] for rule in response.json()["data"]["rules"]]
assert created_ats == sorted(created_ats, reverse=order == "desc"), f"created_at {order} not monotonic"
def test_pagination(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
pages = []
for offset in (0, 2, 4):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "name", "order": "asc", "limit": 2, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["total"] == 5, f"offset {offset}: total must stay the full filtered count"
pages.append([rule["alert"] for rule in data["rules"]])
assert [len(page) for page in pages] == [2, 2, 1]
flattened = [name for page in pages for name in page]
assert len(flattened) == len(set(flattened)), "pages must be disjoint"
assert set(flattened) == {r["alert"] for r in SEED_RULES}
# state sort is almost all ties (four inactive rules); the name/id tiebreak
# must keep the pages disjoint and in the same order on every request
tie_pages = []
for offset in (0, 2, 4):
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"sort": "state", "order": "desc", "limit": 2, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
tie_pages.append([rule["alert"] for rule in response.json()["data"]["rules"]])
assert [name for page in tie_pages for name in page] == [
"infra cpu saturation",
"payment gateway errors",
"payment latency high",
"prom uptime probe",
"checkout conversion drop",
], "tied rows must not shuffle between page requests"
# a past-the-end offset returns an empty page but keeps the real total
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 2, "offset": 50},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["rules"] == []
assert data["total"] == 5
# an over-max limit is clamped, not rejected
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 6000},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["total"] == 5
def test_error_contract(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cases = [
({"query": "created_by ==== ((("}, "rule_list_filter_invalid", "invalid filter query:"),
({"query": "team = 'infra'"}, "rule_list_filter_invalid", 'unknown filter key "team"'),
({"query": "state = 'firing'"}, "rule_list_filter_invalid", 'unknown filter key "state"'),
({"query": "alert_type = 'bogus'"}, "rule_list_filter_invalid", "METRIC_BASED_ALERT"),
({"query": "name REGEXP 'x.*'"}, "rule_list_filter_invalid", "operator REGEXP is not allowed"),
({"query": "created_at >= 'yesterday'"}, "rule_list_filter_invalid", "invalid RFC3339 timestamp"),
({"query": "name LIKE 'prod\\\\'"}, "rule_list_filter_invalid", "must not end with an unescaped backslash"),
({"states": ["bogus"]}, "rule_list_invalid", 'invalid state "bogus"'),
({"sort": "bogus"}, "rule_list_invalid", "invalid sort"),
({"order": "bogus"}, "rule_list_invalid", "invalid order"),
({"limit": -1}, "rule_list_invalid", "invalid limit"),
({"offset": -1}, "rule_list_invalid", "invalid offset"),
]
for params, expected_code, expected_message_part in cases:
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"params {params!r}: {response.text}"
error = response.json()["error"]
assert error["code"] == expected_code, f"params {params!r}"
assert expected_message_part in error["message"], f"params {params!r}: {error['message']}"
def test_v2_list_still_serves_bare_array(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
seed_alert_rules: Callable[[dict, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL, SEED_RULES)
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
data = response.json()["data"]
assert isinstance(data, list), "deprecated v2 must keep returning a bare array"
assert {rule["alert"] for rule in data} == {r["alert"] for r in SEED_RULES}

View File

@@ -0,0 +1,269 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rule_views"
@pytest.mark.parametrize(
("body", "expected_code", "expected_message"),
[
({"data": {"version": "v1"}}, "rule_view_invalid_input", "name is required"),
({"name": " ", "data": {"version": "v1"}}, "rule_view_invalid_input", "name is required"),
(
{"name": " Storage ", "data": {"version": "v1"}},
"rule_view_invalid_input",
"name must not have leading or trailing whitespace",
),
(
{"name": "x" * 65, "data": {"version": "v1"}},
"rule_view_invalid_input",
"name must be at most 64 characters, got 65",
),
(
{"name": "wrong-version", "data": {"version": "v2"}},
"rule_view_invalid_input",
'version must be "v1", got "v2"',
),
(
{"name": "missing-version", "data": {}},
"rule_view_invalid_input",
'version must be "v1", got ""',
),
(
{"name": "bad-state", "data": {"version": "v1", "states": ["exploding"]}},
"rule_list_invalid",
'invalid state "exploding"',
),
(
{"name": "bad-sort", "data": {"version": "v1", "sort": "bogus"}},
"rule_list_invalid",
"invalid sort",
),
(
{"name": "bad-order", "data": {"version": "v1", "order": "bogus"}},
"rule_list_invalid",
"invalid order",
),
(
{"name": "long-query", "data": {"version": "v1", "query": "x" * 1025}},
"rule_list_invalid",
"query cannot be longer than 1024 characters",
),
(
{"name": "rejects-unknown", "data": {"version": "v1"}, "unknownfield": "boom"},
"rule_view_invalid_input",
"invalid saved view request body",
),
],
ids=[
"missing_name",
"blank_name",
"whitespace_name",
"name_too_long",
"wrong_schema_version",
"missing_version",
"invalid_state",
"invalid_sort",
"invalid_order",
"query_too_long",
"unknown_field",
],
)
def test_create_rejects_invalid_body(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
body: dict,
expected_code: str,
expected_message: str,
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == expected_code
assert expected_message in response.json()["error"]["message"]
def test_update_rejects_malformed_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
json={"name": "x", "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_update_missing_view_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={"name": "x", "data": {"version": "v1"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "rule_view_not_found"
def test_delete_rejects_malformed_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_delete_missing_view_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.json()["error"]["code"] == "rule_view_not_found"
def test_rule_view_lifecycle(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_rule_view: Callable[[dict], dict],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# List assertions filter on this test's names so foreign views never interfere.
owned_names = {"Critical Prod", "Critical Staging", "Disabled"}
created = create_rule_view(
{
"name": "Critical Prod",
"data": {
"version": "v1",
"query": "name CONTAINS 'prod' AND severity = 'critical'",
"states": ["firing", "pending"],
"sort": "name",
"order": "asc",
},
}
)
view_id = created["id"]
assert created["name"] == "Critical Prod"
assert created["data"]["version"] == "v1"
assert created["data"]["query"] == "name CONTAINS 'prod' AND severity = 'critical'"
assert created["data"]["states"] == ["firing", "pending"]
# Omitted states, sort and order are normalized on save: [] and the list defaults, never null.
disabled = create_rule_view({"name": "Disabled", "data": {"version": "v1"}})
assert disabled["name"] == "Disabled"
assert disabled["data"]["states"] == []
assert disabled["data"]["sort"] == "updated_at"
assert disabled["data"]["order"] == "desc"
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
views = [v for v in response.json()["data"]["views"] if v["name"] in owned_names]
assert {v["name"] for v in views} == {"Critical Prod", "Disabled"}
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"name": "Critical Staging",
"data": {
"version": "v1",
"query": "name CONTAINS 'staging'",
"states": ["firing"],
"sort": "created_at",
"order": "desc",
},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["id"] == view_id
assert updated["name"] == "Critical Staging"
assert updated["data"]["query"] == "name CONTAINS 'staging'"
assert updated["data"]["states"] == ["firing"]
assert updated["data"]["sort"] == "created_at"
assert updated["data"]["order"] == "desc"
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
listed = {v["name"]: v for v in response.json()["data"]["views"] if v["name"] in owned_names}
assert set(listed) == {"Critical Staging", "Disabled"}
assert listed["Critical Staging"]["data"]["query"] == "name CONTAINS 'staging'"
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NO_CONTENT
)
response = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert {v["name"] for v in response.json()["data"]["views"] if v["name"] in owned_names} == {"Disabled"}
assert (
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).status_code
== HTTPStatus.NOT_FOUND
)