Compare commits

..

66 Commits

Author SHA1 Message Date
Naman Verma
982c175819 Merge branch 'main' into nv/alerts-migration 2026-11-09 12:19:41 +05:30
Vinicius Lourenço
87bcd1b702 test(saved-views): cover behaviour the current tests miss (#13301)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

Stacked on #13287, so that one merges first.

The saved views tests touch about 98% of the lines, yet a lot of
behaviour could break without any of them failing. This adds tests for
that behaviour:

- The row link and Copy link follow the selected time range.
- Saving from the header: a failed update, blank names, pressing Enter
twice, keeping the view color, and saving the query that was run.
- The hover card while it loads, when it fails, and when it closes.
- Restoring the last used view, per source.
- The unsaved changes check and spec conversion against what the server
stores.

It also adds the first saved views e2e specs, run against a real
backend:

- Open, create, update, discard, delete and rename a view on the logs
explorer, and check the header is still clean after a reload.
- Row links and Copy link carry the time range, and a Ctrl/Cmd or middle
click opens the view in a new tab.

While adding the tests I found that renaming a view from the row menu
sent the list's copy of the view back as a full replacement, so a stale
list could undo someone else's edit. Rename now reads the stored view
first, and falls back to the list's copy when the user cannot read the
view.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6397

#### Additional Information

- Each new test fails when the code it covers is broken on purpose.
- Jest: `pnpm jest src/container/SavedViews
src/utils/__tests__/buildExplorerNavigationUrl.test.ts`
- E2E, with the stack up (`pnpm env:start` in `tests/e2e`): `pnpm
playwright test tests/saved-views --project=chromium`
- Opening a view the user cannot read still sends the GET, from
`usePreferenceSync`, which has no read check. The server answers 403 and
the header shows "No view access".
2026-11-08 17:53:07 +00:00
Naman Verma
73226370ae fix: remove omitempty from alertonabsent 2026-11-06 16:37:54 +05:30
Naman Verma
10950a6cc3 Merge branch 'main' into nv/alerts-migration 2026-11-06 16:27:08 +05:30
Naman Verma
65020e11cd fix: migrate v1 rules w nil rule condition as disabled 2026-11-05 12:11:38 +05:30
Naman Verma
bc09e22bda fix: backfill selected query name in migrated rules if absent 2026-11-05 11:07:24 +05:30
Naman Verma
c0f1ce32cc Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-04 21:21:30 +05:30
Naman Verma
c21f9f679d test: update file number 2026-11-04 21:21:22 +05:30
Naman Verma
08f37d3b26 Merge branch 'main' into nv/alerts-migration 2026-11-04 21:20:35 +05:30
Naman Verma
15fe270867 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-03 18:25:00 +05:30
Naman Verma
602d959c6d Merge branch 'main' into nv/alerts-migration 2026-11-03 18:24:38 +05:30
Naman Verma
5f46464c44 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-11-02 11:31:56 +05:30
Naman Verma
f5e9786f91 Merge branch 'main' into nv/alerts-migration 2026-11-02 11:25:18 +05:30
Naman Verma
1b2a5e54f2 fix: make selected query mandatory 2026-10-29 13:00:32 +05:30
Naman Verma
f1624b5d2f test: integration tests 2026-10-28 01:22:12 +05:30
Naman Verma
0e25da12c3 fix: remove -v7 from err msgs 2026-10-28 01:22:00 +05:30
Naman Verma
4ad572a8c2 fix: proper err msg if internal name of channel is referred 2026-10-28 01:21:25 +05:30
Naman Verma
d3d53c8b98 chore: reduce diff 2026-10-27 23:13:44 +05:30
Naman Verma
b380686577 chore: inline rule type extraction 2026-10-27 23:05:45 +05:30
Naman Verma
1d0328fae5 chore: no rejection of empty string 2026-10-27 22:54:56 +05:30
Naman Verma
01b2b49521 chore: keep schemaversion as string type 2026-10-27 22:42:51 +05:30
Naman Verma
52f4657f57 chore: move rule unmarshall to types 2026-10-27 22:18:12 +05:30
Naman Verma
ba475bdb48 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 22:07:53 +05:30
Naman Verma
94113eeed5 fix: make version field noop 2026-10-27 19:20:53 +05:30
Naman Verma
680f7de69e fix: empty query rules should migrate as disabled 2026-10-27 18:23:50 +05:30
Naman Verma
2237dbde9a fix: migrate rules with nil query as disabled 2026-10-27 18:06:38 +05:30
Naman Verma
aac321de58 chore: -ve eval window should migrate as disabled 2026-10-27 17:55:27 +05:30
Naman Verma
7a7c35eca0 chore: method reordering 2026-10-27 17:33:54 +05:30
Naman Verma
41d81cfcbc chore: method reordering 2026-10-27 17:29:43 +05:30
Naman Verma
00afd873e3 chore: split err checks 2026-10-27 17:28:51 +05:30
Naman Verma
428acb6f23 chore: clearer method name 2026-10-27 17:27:16 +05:30
Naman Verma
736ad2aba3 chore: no inline conversions in convertRule 2026-10-27 17:19:13 +05:30
Naman Verma
99f21adadd chore: simplify route validation 2026-10-27 17:11:32 +05:30
Naman Verma
bdfadbbe87 chore: remove unneeded check for schema version 2026-10-27 17:03:18 +05:30
Naman Verma
e23f96c7a1 chore: var renames 2026-10-27 16:59:14 +05:30
Naman Verma
3e1d76f3c6 chore: move type inline to its only usage 2026-10-27 16:55:08 +05:30
Naman Verma
6b06bacb91 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 16:28:53 +05:30
Naman Verma
5b91346a27 Merge branch 'main' into nv/alerts-migration 2026-10-27 16:26:05 +05:30
Naman Verma
a66d6000e5 feat(ruler): reject unknown keys, numeric durations and inverted recovery targets on write 2026-10-27 16:15:25 +05:30
Naman Verma
f42084ad31 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 15:25:51 +05:30
Naman Verma
7525667f28 fix: check for nil evaluation envelope 2026-10-27 15:25:39 +05:30
Naman Verma
bdad1235aa chore: reduce v2 schema migration scope 2026-10-27 15:24:56 +05:30
Naman Verma
a7e0ac1f4d chore: increase migration number 2026-10-27 12:25:30 +05:30
Naman Verma
3a6ae85dee Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-27 12:25:02 +05:30
Naman Verma
1e4c96907d Merge branch 'main' into nv/alerts-migration 2026-10-27 12:24:29 +05:30
Naman Verma
b4191b9e10 fix: repair v2 rules in another migration 2026-10-26 18:27:41 +05:30
Naman Verma
7e043b1221 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 16:58:06 +05:30
Naman Verma
55e76a182d Merge branch 'main' into nv/alerts-migration 2026-10-26 16:57:45 +05:30
Naman Verma
9135807532 chore: reduce diff 2026-10-26 11:22:55 +05:30
Naman Verma
a0d671e0e8 Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 11:02:46 +05:30
Naman Verma
a5f5dc4fb6 Merge branch 'main' into nv/alerts-migration 2026-10-26 11:02:32 +05:30
Naman Verma
b18258e76f Merge branch 'nv/alerts-migration' into nv/rules-v2-validations 2026-10-26 05:46:28 +05:30
Naman Verma
872b4172d3 fix: migrate broken v1 rules as disabled rules 2026-10-26 05:44:15 +05:30
Naman Verma
08f4c099f6 fix: add more validations on v2 schema rules 2026-10-26 04:17:37 +05:30
Naman Verma
87b1eed20a chore: remove extra v2 validations 2026-10-26 04:14:03 +05:30
Naman Verma
f6cfcdfc78 chore: more rule validations 2026-10-26 03:52:16 +05:30
Naman Verma
37cf5ae407 fix: fix all non-query related post-migration errors 2026-10-25 23:59:21 +05:30
Naman Verma
9a8f66be63 Merge branch 'main' into nv/alerts-migration 2026-10-25 22:06:04 +05:30
Naman Verma
3a5078e29b Merge branch 'main' into nv/alerts-migration 2026-10-22 02:05:57 +05:30
Naman Verma
7ebb1910b9 fix: remove nilnil return 2026-10-21 13:50:18 +05:30
Naman Verma
1e1d6f8d24 Merge branch 'main' into nv/alerts-migration 2026-10-21 13:39:57 +05:30
Naman Verma
6f6eea7187 fix: 400 on malformed JSON, missing alertType, unknown enums 2026-10-19 12:00:16 +05:30
Naman Verma
0bec6c5494 Merge branch 'main' into nv/alerts-migration 2026-10-18 09:41:38 +05:30
Naman Verma
d6744c1d8e chore: remove v1 rule apis 2026-10-16 15:20:38 +05:30
Naman Verma
6476be26bb Merge branch 'main' into nv/alerts-migration
# Conflicts:
#	pkg/query-service/app/http_handler.go
2026-10-16 14:56:00 +05:30
Naman Verma
06c54c6483 feat: migrate all rules to v2 schema 2026-10-16 11:17:10 +05:30
60 changed files with 4016 additions and 2249 deletions

View File

@@ -11743,20 +11743,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
labels:
additionalProperties:
type: string
@@ -11768,13 +11758,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11884,20 +11867,10 @@ components:
description: Whether the rule is paused. A disabled rule is neither evaluated
nor notifies. Defaults to false.
type: boolean
evalWindow:
description: The lookback window as a Go duration such as 5m. Only read
when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec
instead.
type: string
evaluation:
allOf:
- $ref: '#/components/schemas/RuletypesEvaluationEnvelope'
description: The evaluation window, as a kind and spec.
frequency:
description: How often the rule is evaluated, as a Go duration such as 1m.
Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set
it in evaluation.spec instead.
type: string
id:
description: The unique identifier of the rule.
type: string
@@ -11912,13 +11885,6 @@ components:
allOf:
- $ref: '#/components/schemas/RuletypesNotificationSettings'
description: Grouping, re-notification and routing behaviour.
preferredChannels:
description: The displayName of each notification channel to notify, for
schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries
its own channels.
items:
type: string
type: array
ruleType:
allOf:
- $ref: '#/components/schemas/RuletypesRuleType'
@@ -11972,28 +11938,10 @@ components:
description: Whether to fire a nodata alert when the query returns no data.
Defaults to false.
type: boolean
algorithm:
description: 'anomaly_rule only: the anomaly detection algorithm. The supported
value is standard.'
type: string
compositeQuery:
allOf:
- $ref: '#/components/schemas/RuletypesAlertCompositeQuery'
description: The queries whose result is compared against the thresholds.
matchType:
allOf:
- $ref: '#/components/schemas/RuletypesMatchType'
description: 'schemaVersion v1 only: which points in the window must satisfy
op. Accepted as at_least_once, all_the_times, on_average, in_total or
last, their aliases avg and sum, or the legacy codes as strings, ''1''
to ''5''. Required for v1; v2alpha1 sets matchType on each threshold.'
op:
allOf:
- $ref: '#/components/schemas/RuletypesCompareOperator'
description: 'schemaVersion v1 only: how the value is compared with target.
Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal
or outside_bounds, their symbols, or the legacy codes as strings, ''1''
to ''7''. Required for v1; v2alpha1 sets op on each threshold.'
requireMinPoints:
description: Whether a series is skipped until it has at least requiredNumPoints
points in the window. Defaults to false.
@@ -12011,17 +11959,6 @@ components:
description: The name of the query whose result is evaluated, such as A
or F1. Must match a query in compositeQuery.
type: string
target:
description: 'schemaVersion v1 only: the value compared against, in targetUnit.
Required for v1; v2alpha1 sets target on each threshold.'
format: double
nullable: true
type: number
targetUnit:
description: 'schemaVersion v1 only: the unit of target, such as percent
or ms. Converted to compositeQuery.unit before comparison when the two
differ.'
type: string
thresholds:
allOf:
- $ref: '#/components/schemas/RuletypesRuleThresholdData'
@@ -25528,56 +25465,6 @@ paths:
- rules
x-signoz-stability: alpha
/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":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/RuletypesRule'
type: array
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 alert rules
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint creates a new alert rule

View File

@@ -34,8 +34,7 @@ type AnomalyRule struct {
provider anomaly.Provider
version string
logger *slog.Logger
logger *slog.Logger
seasonality anomaly.Seasonality
}
@@ -63,7 +62,6 @@ func NewAnomalyRule(
r := AnomalyRule{
BaseRule: baseRule,
querier: querier,
version: p.Version,
logger: logger.With(slog.String("rule.id", id)),
}
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
}
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval.window_ms", end-start),
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
)
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { tags } from '@lezer/highlight';
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
import { githubLightStyle } from '@uiw/codemirror-theme-github';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { act, render, screen } from 'tests/test-utils';
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(),
}));
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
typeof useIsDarkMode
>;
const EXPRESSION = "service.name = 'frontend'";
function stringColor(styles: typeof copilotDarkStyle): string {
return (styles ?? []).find(({ tag }) =>
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
)?.color as string;
}
describe('SavedViewHighlightedExpression', () => {
it.each([
['dark', true, copilotDarkStyle],
['light', false, githubLightStyle],
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
mockUseIsDarkMode.mockReturnValue(isDarkMode);
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
expect(screen.getByText("'frontend'")).toHaveStyle({
color: stringColor(styles),
});
});
it('takes the new theme colours when the theme changes', () => {
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
function useSwitchableDarkMode(): boolean {
const [isDarkMode, setter] = useState(true);
setIsDarkMode = setter;
return isDarkMode;
}
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
act(() => setIsDarkMode(false));
expect(screen.getByText("'frontend'")).toHaveStyle({
color: stringColor(githubLightStyle),
});
});
});

View File

@@ -5,11 +5,19 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -222,6 +230,31 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
});
it('does not open when the row menu is pressed before the delay', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toBeNull();
});
it('closes on escape, leaving nothing of the popover behind', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await renderPanel();
await openCard('Errors');
await user.keyboard('{Escape}');
expect(card()).toBeNull();
expect(
document.querySelector('[data-radix-popper-content-wrapper]'),
).toBeNull();
});
});
describe('content', () => {
@@ -312,6 +345,78 @@ describe('SavedViewHoverCard', () => {
await waitFor(() => expect(fetches).toBe(2));
});
it('shows a skeleton, not the failure, while the view loads', async () => {
let requested = false;
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
rest.get(`${API}/:id`, async (_req, res, ctx) => {
requested = true;
await gate;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await waitFor(() => expect(requested).toBe(true));
expect(
screen.getByTestId('saved-view-hover-card-loading'),
).toBeInTheDocument();
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
release();
await screen.findByTestId('saved-view-hover-card-query');
});
it('shows a skeleton while the read check is pending', async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
transaction.relation === 'read';
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
isRead(transaction) && transaction.object.selector === '*';
// Read on every view would skip the per-view check, so it is denied.
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
if (
payload.some(
(transaction) => isRead(transaction) && !isReadAll(transaction),
)
) {
await gate;
}
return res(
ctx.json(
authzMockResponse(
payload,
payload.map((transaction) => !isReadAll(transaction)),
),
),
);
}),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await expect(
screen.findByTestId('saved-view-hover-card-loading'),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
release();
await screen.findByTestId('saved-view-hover-card-query');
});
it('says the view failed to load when its fetch fails', async () => {
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>

View File

@@ -339,6 +339,45 @@ describe('SavedViews - AuthZ', () => {
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables save as new without create, keeping update', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(SavedViewCreatePermission));
const { history } = renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
);
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
await expect(
settledRow('saved-views-save-changes-item-save-as-new'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-save-changes-item-update'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('offers clear on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
});
it('says no access when the check denies read, even if the server returns the view', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));

View File

@@ -3,6 +3,7 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -75,4 +76,39 @@ describe('SavedViewsHeader last used view', () => {
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
it('does not bring the view back once it is cleared', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
it('forgets a last used view that no longer loads', async () => {
mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(
JSON.parse(
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
),
).toStrictEqual({}),
);
expect(chip()).toHaveTextContent('My view');
});
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
});
});

View File

@@ -1,10 +1,14 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -19,7 +23,7 @@ import {
urlParam,
viewUrl,
} from './savedViewsTestUtils';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -43,6 +47,27 @@ function renderHeader(
);
}
// Edits the current query without running it, so it is not the staged one.
function EditCurrentQuery(): JSX.Element {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
return (
<Button
size="md"
variant="solid"
color="primary"
testId="edit-current-query"
onClick={(): void =>
handleSetQueryData(0, {
...currentQuery.builder.queryData[0],
filter: { expression: 'unstaged = true' },
})
}
>
Edit query
</Button>
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
@@ -160,6 +185,76 @@ describe('SavedViewsHeader saving', () => {
expect(toast.success).not.toHaveBeenCalled();
});
it('does not create a view from a blank name submitted with Enter', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(requests.created).toHaveLength(0);
expect(isModalOpen()).toBe(true);
});
it('ignores Enter while the view is being saved', async () => {
let creates = 0;
mockSavedViewsApi([]);
server.use(
rest.post(API, (_req, res, ctx) => {
creates += 1;
return res(
ctx.delay(400),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'view-new' } }),
);
}),
);
renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await submitName('Slow checkout');
await waitFor(() => expect(creates).toBe(1));
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(creates).toBe(1);
});
it('saves the query that was run, not edits made since', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>
<EditCurrentQuery />
</>,
explorerUrl(PATH, {}),
);
await userEvent.click(screen.getByTestId('edit-current-query'));
await clickCreate();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
};
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
'unstaged',
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
).not.toContain('unstaged');
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
@@ -231,6 +326,37 @@ describe('SavedViewsHeader saving', () => {
expect(requests.created).toHaveLength(0);
});
it('update that fails reports the error and never says the view was updated', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
expect(requests.updated).toHaveLength(1);
expect(toast.success).not.toHaveBeenCalled();
});
it('update keeps the color of the open view', async () => {
const base = makeView({ id: 'view-1', displayName: 'Errors' });
const colored = {
...base,
spec: { ...base.spec, display: { color: '#e5484d' } },
};
const requests = mockSavedViewsApi([colored]);
await openDirtyView(colored);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { body } = requests.updated[0] as {
body: { spec: { display?: { color?: string } } };
};
expect(body.spec.display?.color).toBe('#e5484d');
});
describe('view name toggle with unsaved changes', () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });

View File

@@ -1,5 +1,6 @@
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
@@ -9,6 +10,7 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -219,6 +221,75 @@ describe('SavedViewsPanel list', () => {
});
});
it('lists only the views of its own source', async () => {
mockSavedViewsApi([
mine,
makeView({
id: 'view-9',
displayName: 'Noisy logs',
source: SavedviewtypesSourceDTO.logs,
}),
]);
renderPanel();
await screen.findByTestId('saved-views-list');
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
});
describe('last used view', () => {
const lastUsedViews = (): unknown =>
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
it('is remembered under the panel source, keeping the other sources', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify(logsEntry),
);
mockSavedViewsApi([mine, alsoMine]);
renderPanel();
await screen.findByTestId('saved-views-list');
await waitFor(() => expectUsable(rowName('Slow spans')));
await userEvent.click(rowName('Slow spans'));
await waitFor(() =>
expect(lastUsedViews()).toStrictEqual({
...logsEntry,
traces: { key: alsoMine.id, value: 'Slow spans' },
}),
);
});
it('is filed under the source of the panel it was opened from', async () => {
const logsView = makeView({
id: 'view-9',
displayName: 'Noisy logs',
source: SavedviewtypesSourceDTO.logs,
dataSource: DataSource.LOGS,
});
mockSavedViewsApi([logsView]);
renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.logs}
onClose={jest.fn()}
/>,
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
DataSource.LOGS,
);
await screen.findByTestId('saved-views-list');
await waitFor(() => expectUsable(rowName('Noisy logs')));
await userEvent.click(rowName('Noisy logs'));
await waitFor(() =>
expect(lastUsedViews()).toStrictEqual({
logs: { key: logsView.id, value: 'Noisy logs' },
}),
);
});
});
it('offers to create a view when there are none, and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderPanel();

View File

@@ -4,11 +4,13 @@ import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
@@ -181,6 +183,87 @@ describe('SavedViewsPanel row menu', () => {
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
});
it('renames from the stored view, not a stale list row', async () => {
const views = [errors, slow];
const requests = mockSavedViewsApi(views);
renderPanelWithHeader();
await screen.findByTestId('saved-views-list');
const stored = makeView({
id: slow.id,
displayName: 'Slow spans',
expression: 'duration_nano > 5000',
});
views[1] = stored;
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.clear(name);
await userEvent.type(name, 'Slow checkout spans');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.updated).toHaveLength(1));
expect(requests.updated[0].body).toMatchObject({
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
});
});
it('renames from the list row when the view cannot be read', async () => {
const requests = mockSavedViewsApi([errors, slow]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(403), ctx.json({ status: 'error' })),
),
);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.clear(name);
await userEvent.type(name, 'Slow checkout spans');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.updated).toHaveLength(1));
expect(requests.updated[0].body).toMatchObject({
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
});
});
it('does not rename when the stored view fails to load', async () => {
const requests = mockSavedViewsApi([errors, slow]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.type(name, ' 2');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.updated).toHaveLength(0);
});
it('ignores Enter while the stored view loads', async () => {
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.type(name, ' 2{enter}');
await userEvent.type(name, '{enter}');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.anything(),
),
);
expect(requests.updated).toHaveLength(1);
});
it('shows the new name in the header when the open view is renamed', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader(viewUrl(PATH, errors));
@@ -236,4 +319,19 @@ describe('SavedViewsPanel row menu', () => {
);
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
});
it('keeps the open view when another view is deleted', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
await chooseFromRowMenu('Slow spans', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
expect(chip()).toHaveTextContent('Errors');
});
});

View File

@@ -0,0 +1,111 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { GlobalReducer } from 'types/reducer/globalTime';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
} from './savedViewsTestUtils';
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
// A store whose global time can change while the panel stays mounted.
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
setTime: (time: Partial<GlobalReducer>) => void;
} {
let globalTime: GlobalReducer = {
...store.getState().globalTime,
selectedTime,
};
const timeStore = configureStore([thunk])(() => ({
...store.getState(),
globalTime,
}));
renderWithExplorerProviders(
<Provider store={timeStore}>
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</Provider>,
explorerUrl(PATH, {}),
);
return {
setTime: (time): void => {
globalTime = { ...globalTime, ...time };
act(() => {
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
});
},
};
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const relativeTimeOf = (link: string): string | null =>
new URL(link, window.location.origin).searchParams.get(
QueryParams.relativeTime,
);
describe('SavedViewsPanel time range', () => {
beforeEach(() => {
localStorage.clear();
mockCopy.mockClear();
mockSavedViewsApi([slow]);
});
it('moves the row link to the new time range when it changes', async () => {
const { setTime } = renderPanelWithTime('1h');
await screen.findByTestId('saved-views-list');
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
setTime({ selectedTime: '6h' });
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
});
it('copies a link in the time range that is selected when it is copied', async () => {
const { setTime } = renderPanelWithTime('1h');
await screen.findByTestId('saved-views-list');
setTime({ selectedTime: '6h' });
await userEvent.click(
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
);
const copyLink = await screen.findByTestId(
'saved-views-row-menu-item-copyLink',
);
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
await userEvent.click(copyLink);
expect(mockCopy).toHaveBeenCalledTimes(1);
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
});
});

View File

@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useMutation, useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import {
getGetSavedViewQueryOptions,
updateSavedView,
useDeleteSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
@@ -45,8 +46,39 @@ export function useSavedViewRowActions(
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
// The update replaces the whole spec, so it starts from the stored view, not
// the list row, which can be stale. Without read on the view, the list row is
// the only copy there is.
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
async ({
view,
displayName,
}: {
view: SavedviewtypesSavedViewDTO;
displayName: string;
}) => {
let { spec } = view;
try {
({
data: { spec },
} = await queryClient.fetchQuery(
getGetSavedViewQueryOptions({ id: view.id }),
));
} catch (error) {
if ((error as ErrorType<unknown>).response?.status !== 403) {
throw error;
}
}
await updateSavedView(
{ id: view.id },
{
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...spec, displayName },
},
);
},
);
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
useDeleteSavedView();
@@ -77,14 +109,7 @@ export function useSavedViewRowActions(
displayName: string,
): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...view.spec, displayName },
},
});
await renameSavedView({ view, displayName });
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
@@ -96,7 +121,7 @@ export function useSavedViewRowActions(
return false;
}
},
[updateSavedView, source, queryClient, logSavedViewEvent],
[renameSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail

View File

@@ -127,6 +127,48 @@ describe('hasUnsavedViewChanges', () => {
).toBe(true);
});
it('is clean while no query is staged', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: null,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options,
}),
).toBe(false);
});
it('is dirty when the columns are reordered', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: [columns[1], columns[0]] },
}),
).toBe(true);
});
it('is dirty when a column is swapped for another', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: {
...options,
selectColumns: [
columns[0],
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
],
},
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
@@ -184,6 +226,21 @@ describe('hasUnsavedViewChanges', () => {
).toBe(true);
});
it.each([
['format', { format: 'table' as LogViewMode }],
['font size', { fontSize: FontSize.LARGE }],
])('is dirty when only the %s changes', (_, change) => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting, ...change },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
@@ -295,6 +352,137 @@ describe('hasUnsavedViewChanges', () => {
).toBe(false);
});
it.each([
['point', 'attribute'],
['spanfield', 'span'],
['logfield', 'log'],
['tracefield', 'trace'],
])(
'reads a group-by staged as %s and stored as %s as the same field',
(legacyContext, storedContext) => {
const base = viewFrom(
toSavedViewSpec({
query: query(DataSource.METRICS, ''),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'By os',
}),
);
const [storedQuery] = base.spec.queries;
const storedView = {
...base,
spec: {
...base.spec,
queries: [
{
...storedQuery,
spec: {
...storedQuery.spec,
groupBy: [
{
name: 'os.type',
fieldContext: storedContext,
fieldDataType: 'string',
},
],
},
},
],
},
} as SavedviewtypesSavedViewDTO;
const opened = stagedFrom(storedView, DataSource.METRICS);
const stagedLegacy = {
...opened,
builder: {
...opened.builder,
queryData: opened.builder.queryData.map((queryData) => ({
...queryData,
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
})),
},
} as Query;
expect(
hasUnsavedViewChanges({
view: storedView,
stagedQuery: stagedLegacy,
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
},
);
// The query still staged after a save keeps the builder's values, while
// the server returns the view without them.
describe('right after saving a query that carries builder defaults', () => {
const withPatch = (
dataSource: DataSource,
patch: Partial<Query['builder']['queryData'][number]>,
): Query => {
const base = query(dataSource, '');
return {
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((queryData) => ({
...queryData,
...patch,
})),
},
} as Query;
};
it.each([
[
'a zero offset on a list',
DataSource.LOGS,
SavedviewtypesSourceDTO.logs,
PANEL_TYPES.LIST,
{ offset: 0 },
],
[
'an empty group-by description',
DataSource.TRACES,
SavedviewtypesSourceDTO.traces,
PANEL_TYPES.TIME_SERIES,
{
groupBy: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
description: '',
},
],
},
],
[
'a function without arguments',
DataSource.METRICS,
SavedviewtypesSourceDTO.metrics,
PANEL_TYPES.TIME_SERIES,
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
],
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
const saved = withPatch(
dataSource,
patch as Partial<Query['builder']['queryData'][number]>,
);
const savedView = viewFrom(
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
);
expect(
hasUnsavedViewChanges({
view: savedView,
stagedQuery: saved,
panelType,
sourcePage,
}),
).toBe(false);
});
});
describe('list order', () => {
const withOrderBy = (
staged: Query,

View File

@@ -80,6 +80,66 @@ describe('toSavedViewSpec', () => {
});
});
it('keeps the description and unit of a column, naming it by name before key', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: [
{
name: 'duration_nano',
key: 'durationNano',
description: 'Span duration',
unit: 'ns',
},
] as unknown as TelemetryFieldKey[],
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
]);
});
it.each([
[
PANEL_TYPES.LIST,
SavedviewtypesPanelTypeDTO.list,
Querybuildertypesv5RequestTypeDTO.raw,
],
[
PANEL_TYPES.TABLE,
SavedviewtypesPanelTypeDTO.table,
Querybuildertypesv5RequestTypeDTO.scalar,
],
[
PANEL_TYPES.TRACE,
SavedviewtypesPanelTypeDTO.trace,
Querybuildertypesv5RequestTypeDTO.trace,
],
[
PANEL_TYPES.PIE,
SavedviewtypesPanelTypeDTO.graph,
Querybuildertypesv5RequestTypeDTO.scalar,
],
])(
'stores the %s panel as panel type %s with request type %s',
(panelType, storedPanelType, requestType) => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType,
displayName: 'Errors',
});
expect(spec.panelType).toBe(storedPanelType);
expect(spec.requestType).toBe(requestType);
},
);
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),

View File

@@ -21,6 +21,20 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('encodes the composite query for a reader that decodes it, even with a percent sign', () => {
const query = {
queryType: 'builder',
filter: { expression: "body LIKE '%timeout%'" },
} as never;
const url = buildExplorerNavigationUrl(ROUTES.LOGS_EXPLORER, query, {});
const param = new URL(url, window.location.origin).searchParams.get(
QueryParams.compositeQuery,
) as string;
expect(JSON.parse(decodeURIComponent(param))).toStrictEqual(query);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,

View File

@@ -9,8 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
"github.com/uptrace/bun"
)
type config struct {
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
return channels, nil
}
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
type matcher struct {
bun.BaseModel `bun:"table:rule"`
ID valuer.UUID `bun:"id,pk"`
Data string `bun:"data"`
}
matchers := []matcher{}
err := store.
sqlstore.
BunDB().
NewSelect().
Column("id", "data").
Model(&matchers).
Scan(ctx)
if err != nil {
return nil, err
}
matchersMap := make(map[string][]string)
for _, matcher := range matchers {
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
for _, receiver := range receivers {
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
}
}
return matchersMap, nil
}
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
storeOpts := alertmanagertypes.NewStoreOptions(opts...)

View File

@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
return nil, err
}
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}
for ruleID, receivers := range matchers {
err = config.CreateRuleIDMatcher(ruleID, receivers)
if err != nil {
return nil, err
}
}
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
return config, nil
}

View File

@@ -14,20 +14,6 @@ import (
)
func (provider *provider) addRulerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
ID: "ListRules",
Tags: []string{"rules"},
Summary: "List alert rules",
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"},

View File

@@ -59,11 +59,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
}
}
if builder.evaluation == nil {
evalWindow := rule.EvalWindow
if evalWindow.IsZero() {
evalWindow = valuer.MustParseTextDuration("5m")
}
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
return nil
}
return builder

View File

@@ -60,7 +60,7 @@ func (bc *bucketCache) GetMissRanges(
) (cached *qbtypes.Result, missing []*qbtypes.TimeRange) {
// Get query window
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
startMs, endMs := q.Window()
bc.logger.DebugContext(ctx, "getting miss ranges", slog.String("fingerprint", q.Fingerprint()), slog.Uint64("start", startMs), slog.Uint64("end", endMs))
@@ -127,7 +127,7 @@ func (bc *bucketCache) GetMissRanges(
// Put stores fresh query results in the cache.
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
// Get query window
startMs, endMs := cacheWindow(q, uint64(step.Milliseconds()))
startMs, endMs := q.Window()
// Calculate the flux boundary - data after this point should not be cached
currentMs := uint64(time.Now().UnixMilli())
@@ -152,8 +152,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
// Trim the result to exclude data within flux interval
requestStartMs, _ := q.Window()
trimmedResult := bc.trimResultToFluxBoundary(fresh, requestStartMs, cachableEndMs)
trimmedResult := bc.trimResultToFluxBoundary(fresh, cachableEndMs)
if trimmedResult == nil {
// Result type is not cacheable (raw or scalar)
return
@@ -223,30 +222,6 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
}
}
// lookbackSteps is how many steps before its window a query's statement
// reads. runningDiff drops its first point, so the metrics statement reads
// one step before the window to give the first interval a difference.
func lookbackSteps(q qbtypes.Query) int {
bq, ok := q.(*builderQuery[qbtypes.MetricAggregation])
if !ok {
return 0
}
for _, fn := range bq.spec.Functions {
if fn.Name == qbtypes.FunctionNameRunningDiff {
return 1
}
}
return 0
}
// cacheWindow is the query window widened by its lookback, so a hit carries
// the steps the statement reads before the window.
func cacheWindow(q qbtypes.Query, stepMs uint64) (uint64, uint64) {
startMs, endMs := q.Window()
lookbackMs := uint64(lookbackSteps(q)) * stepMs
return startMs - min(lookbackMs, startMs), endMs
}
// generateCacheKey names the cache entry holding one grid cell of a query.
func (bc *bucketCache) generateCacheKey(q qbtypes.Query, cellIdx uint64) string {
return fmt.Sprintf("v6:query:%s:cellidx:%d", q.Fingerprint(), cellIdx)
@@ -785,7 +760,7 @@ func (bc *bucketCache) deduplicateWarnings(warnings []string) []string {
}
// trimResultToFluxBoundary trims the result to exclude data points beyond the flux boundary.
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestStartMs, fluxBoundary uint64) *qbtypes.Result {
func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, fluxBoundary uint64) *qbtypes.Result {
trimmedResult := &qbtypes.Result{
Type: result.Type,
Stats: result.Stats,
@@ -815,10 +790,8 @@ func (bc *bucketCache) trimResultToFluxBoundary(result *qbtypes.Result, requestS
// Filter values to exclude those beyond flux boundary and partial values
for _, value := range series.Values {
// Skip partial values - they cannot be cached. A lookback
// step before the request window is flagged partial
// against that window but is a whole step, so it stays.
if value.Partial && uint64(value.Timestamp) >= requestStartMs {
// Skip partial values - they cannot be cached
if value.Partial {
continue
}
if uint64(value.Timestamp) <= fluxBoundary {

View File

@@ -71,7 +71,7 @@ func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
},
}
trimmed := cache.trimResultToFluxBoundary(result, 0, 1710000060000)
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)

View File

@@ -814,7 +814,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
// If entire range is missing, execute normally
if cachedResult == nil && len(missingRanges) == 1 {
startMs, endMs := cacheWindow(query, uint64(step.Milliseconds()))
startMs, endMs := query.Window()
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
sem <- struct{}{}
result, err := query.Execute(ctx)
@@ -898,11 +898,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
// Merge cached and fresh results
mergedResult := q.mergeResults(cachedResult, freshResults)
// A gap statement at the start of the cache window reads its own lookback
// step before it, which the request's statement would not read.
if cacheStartMs, _ := cacheWindow(query, uint64(step.Milliseconds())); lookbackSteps(query) > 0 {
dropValuesBefore(mergedResult, cacheStartMs)
}
mergedResult.Stats.RowsScanned += totalStats.RowsScanned
mergedResult.Stats.BytesScanned += totalStats.BytesScanned
mergedResult.Stats.DurationMS += totalStats.DurationMS
@@ -913,18 +908,6 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
return mergedResult, nil
}
func dropValuesBefore(result *qbtypes.Result, fromMs uint64) {
data, ok := result.Value.(*qbtypes.TimeSeriesData)
if !ok || data == nil {
return
}
for _, aggBucket := range data.Aggregations {
for _, series := range aggBucket.Series {
series.Values = slices.DeleteFunc(series.Values, func(value *qbtypes.TimeSeriesValue) bool { return uint64(value.Timestamp) < fromMs })
}
}
}
// createRangedQuery creates a copy of the query with a different time range.
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
// this is called in a goroutine, so we create a copy of the query to avoid race conditions

View File

@@ -3,10 +3,8 @@ package app
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
return nil
}
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
rules, err := aH.ruleManager.ListRuleStates(r.Context())
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
// todo(amol): need to add sorter
aH.Respond(w, rules)
}
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, ruleResponse)
}
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, rule)
}
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully edited")
}
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
err := aH.ruleManager.DeleteRule(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, "rule successfully deleted")
}
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
return
}
RespondError(w, toApiError(err), nil)
return
}
aH.Respond(w, gettableRule)
}
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
if err != nil {
RespondError(w, toApiError(err), nil)
return
}
response := map[string]interface{}{
"alertCount": alertCount,
"message": "notification sent",
}
aH.Respond(w, response)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -34,10 +34,6 @@ type BaseRule struct {
ruleCondition *ruletypes.RuleCondition
Threshold ruletypes.RuleThreshold
// evalWindow is the time window used for evaluating the rule
// i.e. each time we lookback from the current time, we look at data for the last
// evalWindow duration
evalWindow valuer.TextDuration
// holdDuration is the duration for which the alert waits before firing
holdDuration valuer.TextDuration
@@ -49,10 +45,7 @@ type BaseRule struct {
// these are the same for all alerts created for this rule
labels ruletypes.Labels
annotations ruletypes.Labels
// preferredChannels is the list of channels to send the alert to
// if the rule is triggered
preferredChannels []string
mtx sync.Mutex
mtx sync.Mutex
// the time it took to evaluate the rule (most recent evaluation)
evaluationDuration time.Duration
// the timestamp of the last evaluation
@@ -151,26 +144,27 @@ func NewBaseRule(
if err != nil {
return nil, err
}
if err := threshold.Validate(); err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
}
evaluation, err := p.Evaluation.GetEvaluation()
if err != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
}
baseRule := &BaseRule{
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
preferredChannels: p.PreferredChannels,
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
labels: ruletypes.FromMap(p.Labels),
annotations: ruletypes.FromMap(p.Annotations),
health: ruletypes.HealthUnknown,
Active: map[uint64]*ruletypes.Alert{},
Threshold: threshold,
evaluation: evaluation,
}
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
@@ -178,10 +172,6 @@ func NewBaseRule(
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
}
if baseRule.evalWindow.IsZero() {
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
}
for _, opt := range opts {
opt(baseRule)
}
@@ -232,10 +222,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
return r.evalDelay
}
func (r *BaseRule) EvalWindow() valuer.TextDuration {
return r.evalWindow
}
func (r *BaseRule) HoldDuration() valuer.TextDuration {
return r.holdDuration
}
@@ -246,7 +232,6 @@ func (r *BaseRule) Name() string { return r.name }
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
func (r *BaseRule) GeneratorURL() string {
params := url.Values{}

View File

@@ -332,17 +332,28 @@ func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *rule
}
known := make(map[string]struct{}, len(orgChannels))
displayNameByName := make(map[string]string, len(orgChannels))
for _, ch := range orgChannels {
known[ch.DisplayName] = struct{}{}
displayNameByName[ch.Name] = ch.DisplayName
}
var unknown []string
var unknown, byName []string
for _, name := range channels {
if _, ok := known[name]; !ok {
unknown = append(unknown, name)
if _, ok := known[name]; ok {
continue
}
if displayName, ok := displayNameByName[name]; ok {
byName = append(byName, fmt.Sprintf("%s (display name %q)", name, displayName))
continue
}
unknown = append(unknown, name)
}
if len(byName) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channels: rules reference channels by display name, not name: %v", byName)
}
if len(unknown) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"channels: the following channels do not exist: %v", unknown)
@@ -359,7 +370,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
}
orgID := valuer.MustNewUUID(claims.OrgID)
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
err = parsedRule.DecodeStrict(ruleStr)
if err != nil {
return err
}
@@ -438,7 +449,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
})
if err != nil {
m.logger.Error("loading tasks failed", errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
return err
}
for _, r := range newTask.Rules() {
@@ -547,7 +558,7 @@ func (m *Manager) deleteTask(taskName string) {
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
if err := parsedRule.DecodeStrict(ruleStr); err != nil {
return nil, err
}
@@ -659,7 +670,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
})
if err != nil {
m.logger.Error("creating rule task failed", "name", taskName, errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
return err
}
for _, r := range newTask.Rules() {
@@ -1048,8 +1059,10 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
// a rule stored before selectedQueryName was required resolves it the way evaluation does
storedRule.RuleCondition.SelectedQuery = storedRule.RuleCondition.SelectedQueryName()
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
if err := storedRule.DecodeStrict(ruleStr); err != nil {
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
return nil, err
}
@@ -1105,9 +1118,9 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
// sends a test notification. returns alert count and error (if any)
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
parsedRule := ruletypes.PostableRule{}
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
err := parsedRule.DecodeStrict(ruleStr)
if err != nil {
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
return 0, err
}
if err := parsedRule.Validate(); err != nil {
return 0, err

View File

@@ -49,13 +49,13 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
PanelType: ruletypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
@@ -108,12 +108,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
Annotations: map[string]string{
"value": "{{$value}}",
},
Version: "v5",
Version: "v5",
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
RuleCondition: &ruletypes.RuleCondition{
MatchType: ruletypes.AtleastOnce,
SelectedQuery: "A",
CompareOperator: ruletypes.ValueIsAbove,
Target: &target,
SelectedQuery: "A",
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
PanelType: ruletypes.PanelTypeGraph,
@@ -138,7 +136,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
RecoveryTarget: recovery,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
Channels: []string{"slack"},
},
},
},

View File

@@ -23,7 +23,6 @@ import (
type PromRule struct {
*BaseRule
version string
prometheus prometheus.Prometheus
}
@@ -47,7 +46,6 @@ func NewPromRule(
p := PromRule{
BaseRule: baseRule,
version: postableRule.Version,
prometheus: prometheus,
}
@@ -356,12 +354,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *PromRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -690,13 +690,11 @@ func TestPromRuleEval(t *testing.T) {
logger := instrumentationtest.New().Logger()
for idx, c := range cases {
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -905,11 +903,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1198,11 +1192,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1259,6 +1249,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
func TestPromRule_NoData(t *testing.T) {
evalTime := time.Now()
target := 200.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test no data"),
@@ -1269,8 +1260,6 @@ func TestPromRule_NoData(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1282,7 +1271,7 @@ func TestPromRule_NoData(t *testing.T) {
},
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
},
},
}
@@ -1365,10 +1354,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
AlertOnAbsent: true,
Target: &target,
AlertOnAbsent: true,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1491,8 +1477,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypePromQL,
Queries: []qbtypes.QueryEnvelope{
@@ -1562,9 +1546,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
for _, c := range cases {
rc := postableRule.RuleCondition
rc.Target = &targetForNoAlert
target := &targetForNoAlert
if c.alertCondition {
rc.Target = &targetForAlert
target = &targetForAlert
}
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
@@ -1573,9 +1557,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName.StringValue(),
TargetValue: rc.Target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
TargetValue: target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -20,15 +20,12 @@ type Rule interface {
Annotations() ruletypes.Labels
Condition() *ruletypes.RuleCondition
EvalDelay() valuer.TextDuration
EvalWindow() valuer.TextDuration
HoldDuration() valuer.TextDuration
State() ruletypes.AlertState
ActiveAlerts() []*ruletypes.Alert
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
ActiveAlertsLabelFP() map[uint64]struct{}
PreferredChannels() []string
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
Eval(context.Context, time.Time) (int, error)
String() string

View File

@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
}
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
r.logger.InfoContext(
ctx, "prepare query range request v5",
slog.Int64("ts", ts.UnixMilli()),
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
slog.Int64("eval_window", end-start),
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
)
startTs, endTs := r.Timestamps(ts)
start, end := startTs.UnixMilli(), endTs.UnixMilli()
req := &qbtypes.QueryRangeRequest{
Start: uint64(start),
End: uint64(end),
@@ -455,12 +455,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *ThresholdRule) String() string {
ar := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
PreferredChannels: r.preferredChannels,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
Labels: r.labels.Map(),
Annotations: r.annotations.Map(),
}
byt, err := json.Marshal(ar)

View File

@@ -63,6 +63,7 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -171,10 +172,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -183,9 +181,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -239,10 +238,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -252,9 +248,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -304,10 +301,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
Version: "v5",
}
@@ -317,9 +311,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -366,10 +361,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
},
},
},
CompareOperator: ruletypes.ValueIsNotEq,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{0.0}[0],
SelectedQuery: "A",
SelectedQuery: "A",
},
}
@@ -378,9 +370,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
Name: "critical",
TargetValue: &[]float64{0.0}[0],
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsNotEq,
},
},
}
@@ -470,6 +463,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "critical",
TargetValue: &c.target,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
@@ -653,11 +647,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
ExpectQuery(queryString).
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -867,11 +857,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -975,11 +961,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1083,11 +1065,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1256,11 +1234,7 @@ func TestMultipleThresholdRule(t *testing.T) {
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
@@ -1930,8 +1904,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
@@ -2050,7 +2022,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
rc := postableRule.RuleCondition
rc.Target = &c.target
rc.RequireMinPoints = c.requireMinPoints
rc.RequiredNumPoints = c.requiredNumPoints
rc.Thresholds = &ruletypes.RuleThresholdData{
@@ -2059,8 +2030,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
{
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
},
},
}

View File

@@ -3,7 +3,6 @@ package ruler
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)

View File

@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
return &handler{ruler: ruler}
}
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
rules, err := handler.ruler.ListRuleStates(ctx)
if err != nil {
render.Error(rw, err)
return
}
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
for _, rule := range rules.Rules {
view = append(view, ruletypes.NewRule(rule))
}
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()
@@ -72,7 +54,7 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -111,7 +93,7 @@ func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request)
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -130,7 +112,7 @@ func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -156,7 +138,7 @@ func (handler *handler) DeleteRuleByID(rw http.ResponseWriter, req *http.Request
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -175,7 +157,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -279,7 +261,7 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -328,7 +310,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -358,7 +340,7 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -448,7 +430,7 @@ func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}
@@ -484,7 +466,7 @@ func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request
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"))
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
return
}

View File

@@ -272,6 +272,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
sqlmigration.NewRestructureJiraChannelSpecFactory(sqlstore),
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
)
}

View File

@@ -0,0 +1,629 @@
package sqlmigration
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
const (
schemaVersionV2Alpha1 = "v2alpha1"
ruleTypeThreshold = "threshold_rule"
ruleTypeProm = "promql_rule"
ruleTypeAnomaly = "anomaly_rule"
queryTypeBuilder = "builder"
queryTypePromQL = "promql"
queryTypeClickHouseSQL = "clickhouse_sql"
panelTypeGraph = "graph"
alertTypeMetric = "METRIC_BASED_ALERT"
alertTypeTraces = "TRACES_BASED_ALERT"
alertTypeLogs = "LOGS_BASED_ALERT"
signalLogs = "logs"
signalTraces = "traces"
compareOperatorBelow = "2"
criticalThresholdName = "critical"
labelSeverity = "severity"
defaultEvalWindow = "5m"
defaultFrequency = "1m"
)
// The values the write path accepts; a migrated rule outside them is written
// disabled, for the user to fix.
var (
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
)
// The values the write path accepts; a migrated row outside them cannot be
// edited until the field is supplied, so the migration derives them.
var (
supportedAlertTypes = []string{"METRIC_BASED_ALERT", "TRACES_BASED_ALERT", "LOGS_BASED_ALERT", "EXCEPTIONS_BASED_ALERT", "AI_TRACES_BASED_ALERT"}
supportedQueryTypes = []string{"builder", "clickhouse_sql", "promql"}
supportedPanelTypes = []string{"value", "table", "graph"}
)
type migrateRulesV1ToV2Alpha1 struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
// a failed write aborts the run and rolls back every org.
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
var orgIDs []string
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, orgID := range orgIDs {
if err := migration.convertOrg(ctx, orgID); err != nil {
return err
}
}
return nil
})
}
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
return nil
}
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
db := migration.sqlstore.BunDBCtx(ctx)
type ruleRow struct {
bun.BaseModel `bun:"table:rule"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Data string `bun:"data"`
}
allRules := make([]*ruleRow, 0)
if err := db.NewSelect().Model(&allRules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
allRoutePolicies := make([]*routePolicyRow, 0)
if err := db.NewSelect().Model(&allRoutePolicies).Where("org_id = ?", orgID).Scan(ctx); err != nil {
return err
}
// the name is not unique; a v1 rule has one threshold so one row, and any
// duplicate is as good as another
routePolicyNameToRoute := make(map[string]*routePolicyRow)
for _, route := range allRoutePolicies {
routePolicyNameToRoute[route.Name] = route
}
var migrated, disabled, skipped, undecodable, unmigratable int
for _, storedRule := range allRules {
storedRuleData := new(postableRule)
if err := json.Unmarshal([]byte(storedRule.Data), storedRuleData); err != nil {
undecodable++
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
continue
}
if storedRuleData.SchemaVersion == schemaVersionV2Alpha1 {
skipped++
continue
}
storedRoute := routePolicyNameToRoute[storedRule.ID]
if err := validateRoute(storedRoute, storedRuleData.PreferredChannels); err != nil {
unmigratable++
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
continue
}
convertedRule, convertedRoute := convertRule(storedRule.ID, storedRuleData, storedRoute)
if cause := convertedRule.disableIfNeverFired(); cause != "" {
disabled++
migration.logger.WarnContext(ctx, "migrating rule as disabled, it never fired in v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), slog.String("cause", cause))
}
data, err := marshalUnescaped(convertedRule)
if err != nil {
return err
}
storedRule.Data = string(data)
if _, err := db.NewUpdate().Model(storedRule).Column("data").WherePK().Exec(ctx); err != nil {
return err
}
if convertedRoute != nil && convertedRoute.Expression != storedRoute.Expression {
if _, err := db.NewUpdate().Model(convertedRoute).Column("expression").WherePK().Exec(ctx); err != nil {
return err
}
}
migrated++
}
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(allRules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
return nil
}
// validateRoute checks the rule's route_policy row, named after the rule. A
// rule that never had channels got no row from migration 049 and migrates with
// none; a rule with preferred channels but no row would lose them, so it is
// left at v1.
func validateRoute(route *routePolicyRow, preferredChannels []string) error {
if route == nil && len(preferredChannels) > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
}
return nil
}
// convertRule returns a v2alpha1 copy of storedRuleData, derived in the order
// the v1 decode derived the same values, with the rule's route_policy row as it
// must be stored alongside. Version and source are left as stored. A nil
// storedRoute is a rule that never had channels.
func convertRule(ruleID string, storedRuleData *postableRule, storedRoute *routePolicyRow) (*postableRule, *routePolicyRow) {
channels := []string{}
if storedRoute != nil {
channels = storedRoute.Channels
}
rule := storedRuleData.clone()
rule.fillDurationDefaults()
convertedRoute := storedRoute
// a null condition is written back null: nothing derives from it, the v2
// read path rejects it as v1 did, and the list still shows the rule, disabled
if rule.RuleCondition != nil {
rule.deriveAlertType()
rule.deriveQueryType()
rule.repairPanelType()
rule.disableUnmetMinPoints()
rule.deriveRuleType()
rule.deriveSelectedQuery()
rule.negateAnomalyBelowTarget()
convertedRoute = rule.repairEmptySeverity(ruleID, storedRoute)
rule.relocateThreshold(channels)
}
rule.relocateEvaluation()
rule.synthesizeNotificationSettings()
rule.SchemaVersion = schemaVersionV2Alpha1
rule.clearV1Fields()
return rule, convertedRoute
}
type routePolicyRow struct {
bun.BaseModel `bun:"table:route_policy"`
ID string `bun:"id,pk"`
OrgID string `bun:"org_id"`
Name string `bun:"name"`
Expression string `bun:"expression"`
Channels []string `bun:"channels,type:jsonb"`
}
// postableRule mirrors PostableRule key for key, in its order. Keys the
// crosswalk reads or writes are typed; the rest pass through as stored.
type postableRule struct {
AlertName json.RawMessage `json:"alert,omitempty"`
AlertType string `json:"alertType"`
Description json.RawMessage `json:"description,omitempty"`
RuleType string `json:"ruleType,omitempty"`
EvalWindow textDuration `json:"evalWindow,omitzero"`
Frequency textDuration `json:"frequency,omitzero"`
RuleCondition *ruleCondition `json:"condition"`
Labels map[string]string `json:"labels,omitempty"`
Annotations json.RawMessage `json:"annotations,omitempty"`
Disabled json.RawMessage `json:"disabled,omitempty"`
Source json.RawMessage `json:"source,omitempty"`
PreferredChannels []string `json:"preferredChannels,omitempty"`
Version json.RawMessage `json:"version,omitempty"`
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
SchemaVersion string `json:"schemaVersion,omitempty"`
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
}
type ruleCondition struct {
CompositeQuery *compositeQuery `json:"compositeQuery"`
CompareOperator string `json:"op,omitempty"`
Target *float64 `json:"target,omitempty"`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
MatchType string `json:"matchType,omitempty"`
TargetUnit string `json:"targetUnit,omitempty"`
Seasonality string `json:"seasonality,omitempty"`
SelectedQuery string `json:"selectedQueryName,omitempty"`
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
}
type compositeQuery struct {
Queries json.RawMessage `json:"queries,omitempty"`
PanelType string `json:"panelType"`
QueryType string `json:"queryType"`
Unit json.RawMessage `json:"unit,omitempty"`
}
// ruleQueryEnvelope reads the envelope keys the derivations need.
type ruleQueryEnvelope struct {
Type string `json:"type"`
Spec struct {
Name string `json:"name"`
Signal string `json:"signal"`
Disabled bool `json:"disabled"`
} `json:"spec"`
}
type ruleThresholds struct {
Kind string `json:"kind"`
Spec []basicThreshold `json:"spec"`
}
type basicThreshold struct {
Name string `json:"name"`
TargetValue *float64 `json:"target"`
TargetUnit string `json:"targetUnit"`
RecoveryTarget *float64 `json:"recoveryTarget"`
MatchType string `json:"matchType"`
CompareOperator string `json:"op"`
Channels []string `json:"channels"`
}
type evaluationEnvelope struct {
Kind string `json:"kind"`
Spec rollingWindow `json:"spec"`
}
type rollingWindow struct {
EvalWindow textDuration `json:"evalWindow"`
Frequency textDuration `json:"frequency"`
}
type notificationSettings struct {
Renotify *renotify `json:"renotify,omitempty"`
UsePolicy bool `json:"usePolicy"`
}
type renotify struct {
Enabled bool `json:"enabled"`
ReNotifyInterval textDuration `json:"interval,omitzero"`
AlertStates []string `json:"alertStates,omitzero"`
}
// clone copies what the conversion writes to: the rule, its condition, the
// composite query and the labels. Raw keys share their bytes.
func (rule *postableRule) clone() *postableRule {
cloned := *rule
if rule.RuleCondition != nil {
condition := *rule.RuleCondition
if condition.CompositeQuery != nil {
query := *condition.CompositeQuery
condition.CompositeQuery = &query
}
cloned.RuleCondition = &condition
}
cloned.Labels = maps.Clone(rule.Labels)
return &cloned
}
// disableIfNeverFired disables a rule v1 stored but could never fire: a
// negative evalWindow or frequency, which is reset to its default since no
// read of a rolling window accepts it, a null condition or compositeQuery, one
// with no queries or with every query disabled, or a threshold with a null target or
// an op or matchType outside the supported sets, which the v2 write path
// rejects. It reads the relocated fields, so it runs after convertRule, and
// returns the offending fields, or "" when the rule stays enabled.
func (rule *postableRule) disableIfNeverFired() string {
var causes []string
window := &rule.Evaluation.Spec
if !window.EvalWindow.IsPositive() {
causes = append(causes, fmt.Sprintf("evalWindow %s is negative", window.EvalWindow))
window.EvalWindow = mustParseTextDuration(defaultEvalWindow)
}
if !window.Frequency.IsPositive() {
causes = append(causes, fmt.Sprintf("frequency %s is negative", window.Frequency))
window.Frequency = mustParseTextDuration(defaultFrequency)
}
switch {
case rule.RuleCondition == nil:
causes = append(causes, "condition is null")
case rule.RuleCondition.CompositeQuery == nil:
causes = append(causes, "condition.compositeQuery is null")
case len(rule.decodeQueryEnvelopes()) == 0:
causes = append(causes, "condition.compositeQuery.queries is empty")
case rule.RuleCondition.SelectedQuery == "":
causes = append(causes, "condition.selectedQueryName is omitted and every query is disabled")
}
if rule.RuleCondition != nil {
threshold := rule.RuleCondition.Thresholds.Spec[0]
switch {
case threshold.TargetValue == nil:
causes = append(causes, "condition.target is null")
case !slices.Contains(supportedCompareOperators, threshold.CompareOperator):
causes = append(causes, fmt.Sprintf("condition.op: unsupported value %q", threshold.CompareOperator))
case !slices.Contains(supportedMatchTypes, threshold.MatchType):
causes = append(causes, fmt.Sprintf("condition.matchType: unsupported value %q", threshold.MatchType))
}
}
if len(causes) == 0 {
return ""
}
rule.Disabled = json.RawMessage("true")
return strings.Join(causes, ", ")
}
func (rule *postableRule) fillDurationDefaults() {
if rule.EvalWindow.IsZero() {
rule.EvalWindow = mustParseTextDuration(defaultEvalWindow)
}
if rule.Frequency.IsZero() {
rule.Frequency = mustParseTextDuration(defaultFrequency)
}
}
func (rule *postableRule) deriveAlertType() {
if slices.Contains(supportedAlertTypes, rule.AlertType) {
return
}
// promql, clickhouse and formula queries carry no signal; they alert on metrics
rule.AlertType = alertTypeMetric
for _, envelope := range rule.decodeQueryEnvelopes() {
switch envelope.Spec.Signal {
case signalLogs:
rule.AlertType = alertTypeLogs
return
case signalTraces:
rule.AlertType = alertTypeTraces
return
}
}
}
func (rule *postableRule) deriveQueryType() {
query := rule.RuleCondition.CompositeQuery
if query == nil || slices.Contains(supportedQueryTypes, query.QueryType) {
return
}
envelopes := rule.decodeQueryEnvelopes()
if len(envelopes) == 0 {
return
}
// a promql or clickhouse_sql envelope spells its type like the composite query does; every other envelope is a builder one
switch envelopes[0].Type {
case queryTypePromQL, queryTypeClickHouseSQL:
query.QueryType = envelopes[0].Type
default:
query.QueryType = queryTypeBuilder
}
}
func (rule *postableRule) repairPanelType() {
query := rule.RuleCondition.CompositeQuery
// nothing reads panelType; both alert forms write graph
if query != nil && !slices.Contains(supportedPanelTypes, query.PanelType) {
query.PanelType = panelTypeGraph
}
}
func (rule *postableRule) disableUnmetMinPoints() {
condition := rule.RuleCondition
if string(condition.RequireMinPoints) != "true" {
return
}
points := 0.0
if len(condition.RequiredNumPoints) > 0 {
if err := json.Unmarshal(condition.RequiredNumPoints, &points); err != nil {
return
}
}
if points > 0 {
return
}
// zero required points is a check every series passes
condition.RequireMinPoints = json.RawMessage("false")
}
func (rule *postableRule) decodeQueryEnvelopes() []ruleQueryEnvelope {
query := rule.RuleCondition.CompositeQuery
if query == nil {
return nil
}
var envelopes []ruleQueryEnvelope
if err := json.Unmarshal(query.Queries, &envelopes); err != nil {
return nil
}
return envelopes
}
func (rule *postableRule) deriveRuleType() {
query := rule.RuleCondition.CompositeQuery
if query == nil {
return
}
switch query.QueryType {
case queryTypeBuilder, queryTypeClickHouseSQL:
if rule.RuleType == "" {
rule.RuleType = ruleTypeThreshold
}
case queryTypePromQL:
rule.RuleType = ruleTypeProm
}
}
// deriveSelectedQuery fills an omitted selectedQueryName the way v1 resolved it
// at evaluation: F1 when enabled, else the enabled query with the highest name.
func (rule *postableRule) deriveSelectedQuery() {
condition := rule.RuleCondition
if condition.SelectedQuery != "" {
return
}
for _, envelope := range rule.decodeQueryEnvelopes() {
if envelope.Spec.Disabled {
continue
}
if envelope.Spec.Name == "F1" {
condition.SelectedQuery = "F1"
return
}
condition.SelectedQuery = max(condition.SelectedQuery, envelope.Spec.Name)
}
}
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
// evaluates with today.
func (rule *postableRule) negateAnomalyBelowTarget() {
condition := rule.RuleCondition
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
return
}
negated := -*condition.Target
condition.Target = &negated
}
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
// the route expression that matches alerts on it, since a threshold named ""
// fails every later read. It returns storedRoute, or a copy with the new
// expression.
func (rule *postableRule) repairEmptySeverity(ruleID string, storedRoute *routePolicyRow) *routePolicyRow {
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
return storedRoute
}
rule.Labels[labelSeverity] = criticalThresholdName
if storedRoute == nil {
return nil
}
route := *storedRoute
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
return &route
}
func (rule *postableRule) relocateThreshold(channels []string) {
thresholdName := criticalThresholdName
if severity, ok := rule.Labels[labelSeverity]; ok {
thresholdName = severity
}
condition := rule.RuleCondition
condition.Thresholds = &ruleThresholds{
Kind: "basic",
Spec: []basicThreshold{{
Name: thresholdName,
TargetValue: condition.Target,
TargetUnit: condition.TargetUnit,
MatchType: condition.MatchType,
CompareOperator: condition.CompareOperator,
Channels: channels,
}},
}
}
func (rule *postableRule) relocateEvaluation() {
rule.Evaluation = &evaluationEnvelope{
Kind: "rolling",
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
}
}
func (rule *postableRule) synthesizeNotificationSettings() {
alertStates := []string{"firing"}
if rule.RuleCondition != nil && rule.RuleCondition.AlertOnAbsent {
alertStates = append(alertStates, "nodata")
}
rule.NotificationSettings = &notificationSettings{
Renotify: &renotify{
Enabled: true,
ReNotifyInterval: mustParseTextDuration("4h"),
AlertStates: alertStates,
},
UsePolicy: false,
}
}
func (rule *postableRule) clearV1Fields() {
rule.EvalWindow = textDuration{}
rule.Frequency = textDuration{}
rule.PreferredChannels = nil
if condition := rule.RuleCondition; condition != nil {
condition.Target = nil
condition.CompareOperator = ""
condition.MatchType = ""
condition.TargetUnit = ""
}
}
// textDuration decodes a duration the way the app's TextDuration does, a
// string or a number of nanoseconds, and writes a string back in its stored
// spelling.
type textDuration struct {
text string
value time.Duration
}
func mustParseTextDuration(text string) textDuration {
value, err := time.ParseDuration(text)
if err != nil {
panic(err)
}
return textDuration{text: text, value: value}
}
func (d textDuration) IsZero() bool {
return d.value == 0
}
func (d textDuration) IsPositive() bool {
return d.value > 0
}
func (d textDuration) String() string {
if d.text != "" {
return d.text
}
return d.value.String()
}
func (d textDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *textDuration) UnmarshalJSON(data []byte) error {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
switch value := raw.(type) {
case float64:
*d = textDuration{value: time.Duration(value)}
return nil
case string:
parsed, err := time.ParseDuration(value)
if err != nil {
return err
}
*d = textDuration{text: value, value: parsed}
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
}
}

View File

@@ -619,10 +619,6 @@ type ConfigStore interface {
// ListAllChannels returns the list of channels for all organizations.
ListAllChannels(context.Context) ([]*Channel, error)
// GetMatchers gets a list of matchers per organization.
// Matchers is an array of ruleId to receiver names.
GetMatchers(context.Context, string) (map[string][]string, error)
}
// MarshalSecretValue if set to true will expose Secret type

View File

@@ -32,5 +32,10 @@ func (q *Query) Validate() error {
if q.Order != qbtypes.OrderDirectionAsc && q.Order != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "order must be asc or desc")
}
if !q.State.IsZero() {
if err := q.State.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,6 +1,9 @@
package ruletypes
import "github.com/SigNoz/signoz/pkg/valuer"
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
)
type AlertState struct {
valuer.String
@@ -26,6 +29,15 @@ func (AlertState) Enum() []any {
}
}
func (a AlertState) Validate() error {
switch a {
case StateInactive, StatePending, StateRecovering, StateFiring, StateNoData, StateDisabled:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "state: unsupported value %q; must be one of inactive, pending, recovering, firing, nodata, disabled", a.StringValue())
}
}
var alertStateSeverity = map[AlertState]int{
StateInactive: 0,
StatePending: 1,

View File

@@ -5,6 +5,7 @@ import (
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -77,6 +78,15 @@ func (PanelType) Enum() []any {
}
}
func (p PanelType) Validate() error {
switch p {
case PanelTypeValue, PanelTypeTable, PanelTypeGraph:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.panelType: unsupported value %q; must be one of value, table, graph", p.StringValue())
}
}
// Note: this is used to represent the state of the alert query
// i.e the active tab which should be used to represent the selection
@@ -99,6 +109,15 @@ func (QueryType) Enum() []any {
}
}
func (q QueryType) Validate() error {
switch q {
case QueryTypeBuilder, QueryTypeClickHouseSQL, QueryTypePromQL:
return nil
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queryType: unsupported value %q; must be one of builder, clickhouse_sql, promql", q.StringValue())
}
}
type AlertCompositeQuery struct {
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" description:"The queries to evaluate, at least one. Every envelope type must match queryType: builder_query and builder_formula for builder, promql for promql, clickhouse_sql for clickhouse_sql."`
@@ -109,18 +128,13 @@ type AlertCompositeQuery struct {
type RuleCondition struct {
CompositeQuery *AlertCompositeQuery `json:"compositeQuery" required:"true" description:"The queries whose result is compared against the thresholds."`
CompareOperator CompareOperator `json:"op,omitzero" description:"schemaVersion v1 only: how the value is compared with target. Accepted as above, below, equal, not_equal, above_or_equal, below_or_equal or outside_bounds, their symbols, or the legacy codes as strings, '1' to '7'. Required for v1; v2alpha1 sets op on each threshold."`
Target *float64 `json:"target,omitempty" format:"double" description:"schemaVersion v1 only: the value compared against, in targetUnit. Required for v1; v2alpha1 sets target on each threshold."`
AlertOnAbsent bool `json:"alertOnAbsent,omitempty" description:"Whether to fire a nodata alert when the query returns no data. Defaults to false."`
AbsentFor uint64 `json:"absentFor,omitempty" description:"How many minutes without data pass before the nodata alert fires. Ignored unless alertOnAbsent is true."`
MatchType MatchType `json:"matchType,omitzero" description:"schemaVersion v1 only: which points in the window must satisfy op. Accepted as at_least_once, all_the_times, on_average, in_total or last, their aliases avg and sum, or the legacy codes as strings, '1' to '5'. Required for v1; v2alpha1 sets matchType on each threshold."`
TargetUnit string `json:"targetUnit,omitempty" description:"schemaVersion v1 only: the unit of target, such as percent or ms. Converted to compositeQuery.unit before comparison when the two differ."`
Algorithm string `json:"algorithm,omitempty" description:"anomaly_rule only: the anomaly detection algorithm. The supported value is standard."`
AlertOnAbsent bool `json:"alertOnAbsent" description:"Whether to fire a nodata alert when the query returns no data. Defaults to false."`
AbsentFor uint64 `json:"absentFor" description:"How many minutes without data pass before the nodata alert fires. Ignored unless alertOnAbsent is true."`
Seasonality Seasonality `json:"seasonality,omitzero" description:"anomaly_rule only: the seasonal period the history is compared against, hourly, daily or weekly."`
SelectedQuery string `json:"selectedQueryName,omitempty" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
SelectedQuery string `json:"selectedQueryName" required:"true" description:"The name of the query whose result is evaluated, such as A or F1. Must match a query in compositeQuery."`
RequireMinPoints bool `json:"requireMinPoints,omitempty" description:"Whether a series is skipped until it has at least requiredNumPoints points in the window. Defaults to false."`
RequiredNumPoints int `json:"requiredNumPoints,omitempty" description:"The minimum number of points a series needs before it is evaluated. Must be greater than 0 when requireMinPoints is true."`
Thresholds *RuleThresholdData `json:"thresholds,omitempty" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
Thresholds *RuleThresholdData `json:"thresholds" required:"true" description:"The thresholds to evaluate, as a kind and spec."`
}
func (rc *RuleCondition) SelectedQueryName() string {

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
@@ -12,6 +13,7 @@ import (
"github.com/swaggest/jsonschema-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -49,7 +51,6 @@ func (t AlertType) BuilderQueryType() qbtypes.QueryType {
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
)
@@ -61,12 +62,10 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
EvalWindow valuer.TextDuration `json:"evalWindow,omitzero" description:"The lookback window as a Go duration such as 5m. Only read when schemaVersion is v1, defaulting to 5m; v2alpha1 rules set it in evaluation.spec instead."`
Frequency valuer.TextDuration `json:"frequency,omitzero" description:"How often the rule is evaluated, as a Go duration such as 1m. Only read when schemaVersion is v1, defaulting to 1m; v2alpha1 rules set it in evaluation.spec instead."`
AlertName valuer.DisplayName `json:"alert" required:"true" description:"The display name of the alert rule."`
AlertType AlertType `json:"alertType" required:"true" description:"The signal the rule evaluates: METRIC_BASED_ALERT, TRACES_BASED_ALERT, LOGS_BASED_ALERT, EXCEPTIONS_BASED_ALERT or AI_TRACES_BASED_ALERT."`
Description string `json:"description,omitempty" description:"A human readable description of what the rule monitors."`
RuleType RuleType `json:"ruleType" required:"true" description:"The evaluation strategy: threshold_rule compares against thresholds, promql_rule evaluates a PromQL expression, anomaly_rule scores deviation from seasonal history. Defaults to threshold_rule for builder and clickhouse_sql queries and is forced to promql_rule for promql queries."`
RuleCondition *RuleCondition `json:"condition" required:"true" description:"The query to evaluate and the thresholds applied to its result."`
Labels map[string]string `json:"labels,omitempty" description:"Labels attached to every alert the rule fires. Keys match [a-zA-Z_.][a-zA-Z0-9_.]*; values may use Go templates such as {{$labels.service_name}}. severity is the conventional routing label."`
@@ -76,14 +75,12 @@ type PostableRule struct {
Source string `json:"source,omitempty" description:"The URL of the page the rule was created from. Informational; it is not used for evaluation."`
PreferredChannels []string `json:"preferredChannels,omitempty" description:"The displayName of each notification channel to notify, for schemaVersion v1 rules. Ignored for v2alpha1, where each threshold carries its own channels."`
Version string `json:"version" description:"The query payload version. Nothing reads it."`
Evaluation *EvaluationEnvelope `json:"evaluation,omitempty" required:"true" description:"The evaluation window, as a kind and spec."`
SchemaVersion string `json:"schemaVersion,omitempty" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
Evaluation *EvaluationEnvelope `json:"evaluation" required:"true" description:"The evaluation window, as a kind and spec."`
SchemaVersion string `json:"schemaVersion" required:"true" enum:"v2alpha1" description:"The rule payload schema."`
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty" required:"true" description:"Grouping, re-notification and routing behaviour."`
NotificationSettings *NotificationSettings `json:"notificationSettings" required:"true" description:"Grouping, re-notification and routing behaviour."`
}
type NotificationSettings struct {
@@ -228,101 +225,15 @@ func (ns *NotificationSettings) UnmarshalJSON(data []byte) error {
// processRuleDefaults applies the default values
// for the rule options that are blank or unset.
func (r *PostableRule) processRuleDefaults() {
if r.SchemaVersion == "" {
r.SchemaVersion = DefaultSchemaVersion
}
// TODO(srikanthccv): remove as this is now a legacy field
if r.Version == "" {
r.Version = "v5"
}
// v2alpha1 uses the Evaluation envelope for window/frequency;
// only default top-level fields for v1.
if r.SchemaVersion != SchemaVersionV2Alpha1 {
if r.EvalWindow.IsZero() {
r.EvalWindow = valuer.MustParseTextDuration("5m")
}
if r.Frequency.IsZero() {
r.Frequency = valuer.MustParseTextDuration("1m")
}
}
if r.RuleCondition != nil && r.RuleCondition.CompositeQuery != nil {
switch r.RuleCondition.CompositeQuery.QueryType {
case QueryTypeBuilder:
case QueryTypeBuilder, QueryTypeClickHouseSQL:
if r.RuleType.IsZero() {
r.RuleType = RuleTypeThreshold
}
case QueryTypePromQL:
r.RuleType = RuleTypeProm
}
if r.SchemaVersion == DefaultSchemaVersion {
thresholdName := CriticalThresholdName
if r.Labels != nil {
if severity, ok := r.Labels["severity"]; ok {
thresholdName = severity
}
}
// For anomaly detection with ValueIsBelow, negate the target
targetValue := r.RuleCondition.Target
if r.RuleType == RuleTypeAnomaly && r.RuleCondition.CompareOperator == ValueIsBelow && targetValue != nil {
negated := -1 * *targetValue
targetValue = &negated
}
thresholdData := RuleThresholdData{
Kind: BasicThresholdKind,
Spec: BasicRuleThresholds{{
Name: thresholdName,
TargetUnit: r.RuleCondition.TargetUnit,
TargetValue: targetValue,
MatchType: r.RuleCondition.MatchType,
CompareOperator: r.RuleCondition.CompareOperator,
Channels: r.PreferredChannels,
}},
}
r.RuleCondition.Thresholds = &thresholdData
r.Evaluation = &EvaluationEnvelope{RollingEvaluation, RollingWindow{EvalWindow: r.EvalWindow, Frequency: r.Frequency}}
r.NotificationSettings = &NotificationSettings{
Renotify: &Renotify{
Enabled: true,
ReNotifyInterval: valuer.MustParseTextDuration("4h"),
AlertStates: []AlertState{StateFiring},
},
}
if r.RuleCondition.AlertOnAbsent {
r.NotificationSettings.Renotify.AlertStates = append(r.NotificationSettings.Renotify.AlertStates, StateNoData)
}
}
}
}
func (r *PostableRule) MarshalJSON() ([]byte, error) {
type Alias PostableRule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
@@ -336,6 +247,20 @@ func (r *PostableRule) UnmarshalJSON(bytes []byte) error {
return r.validate()
}
func (r *PostableRule) DecodeStrict(ruleStr string) error {
err := json.Unmarshal([]byte(ruleStr), r)
if err == nil {
// the alias has no UnmarshalJSON, so a strict pass sees every key the rule has no field for
type plainRule PostableRule
return binding.JSON.BindBody(strings.NewReader(ruleStr), new(plainRule), binding.WithDisallowUnknownFields(true), binding.WithUnknownFieldContext("alert rule"))
}
if errors.Ast(err, errors.TypeInvalidInput) {
return err
}
// a syntax error is raised before any UnmarshalJSON runs, so it is the one untyped error
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to parse json: %v", err)
}
var _ jsonschema.Preparer = PostableRule{}
// PrepareJSONSchema marks version and source deprecated: nothing reads them,
@@ -376,10 +301,6 @@ func (r *PostableRule) validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
for k, v := range r.Labels {
if !isValidLabelName(k) {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid label name: %s", k))
@@ -423,11 +344,9 @@ func (r *PostableRule) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition: field is required")
}
if r.Version != "v5" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "version: only v5 is supported, got %q", r.Version))
}
if r.AlertType != "" {
if r.AlertType == "" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alertType: field is required"))
} else {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
default:
@@ -452,6 +371,12 @@ func (r *PostableRule) Validate() error {
if r.RuleCondition.CompositeQuery == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery: field is required"))
} else {
if err := r.RuleCondition.CompositeQuery.QueryType.Validate(); err != nil {
errs = append(errs, err)
}
if err := r.RuleCondition.CompositeQuery.PanelType.Validate(); err != nil {
errs = append(errs, err)
}
if len(r.RuleCondition.CompositeQuery.Queries) == 0 {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.compositeQuery.queries: must have at least one query"))
} else {
@@ -462,7 +387,9 @@ func (r *PostableRule) Validate() error {
}
}
if r.RuleCondition.SelectedQuery != "" && r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
if r.RuleCondition.SelectedQuery == "" {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.selectedQueryName: field is required"))
} else if r.RuleCondition.CompositeQuery != nil && len(r.RuleCondition.CompositeQuery.Queries) > 0 {
found := false
for _, query := range r.RuleCondition.CompositeQuery.Queries {
if query.GetQueryName() == r.RuleCondition.SelectedQuery {
@@ -514,74 +441,43 @@ func (r *PostableRule) Validate() error {
func (r *PostableRule) validateSchemaVersion() []error {
switch r.SchemaVersion {
case DefaultSchemaVersion:
return r.validateV1()
case SchemaVersionV2Alpha1:
return r.validateV2Alpha1()
default:
return []error{errors.NewInvalidInputf(errors.CodeInvalidInput,
"schemaVersion: unsupported value %q; must be one of %q, %q",
r.SchemaVersion, DefaultSchemaVersion, SchemaVersionV2Alpha1)}
"schemaVersion: unsupported value %q; must be %q",
r.SchemaVersion, SchemaVersionV2Alpha1)}
}
}
func (r *PostableRule) validateV1() []error {
var errs []error
if r.RuleCondition.Target == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.target: field is required for schemaVersion %q", DefaultSchemaVersion))
}
if r.RuleCondition.CompareOperator.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.op: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.CompareOperator.Validate(); err != nil {
errs = append(errs, err)
}
if r.RuleCondition.MatchType.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.matchType: field is required for schemaVersion %q", DefaultSchemaVersion))
} else if err := r.RuleCondition.MatchType.Validate(); err != nil {
errs = append(errs, err)
}
return errs
}
func (r *PostableRule) validateV2Alpha1() []error {
var errs []error
// TODO(srikanthccv): reject v1-only fields?
// if r.RuleCondition.Target != nil {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.target: field is not used in schemaVersion %q; set target in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.CompareOperator.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.op: field is not used in schemaVersion %q; set op in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if !r.RuleCondition.MatchType.IsZero() {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "condition.matchType: field is not used in schemaVersion %q; set matchType in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// if len(r.PreferredChannels) > 0 {
// errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
// "preferredChannels: field is not used in schemaVersion %q; set channels in condition.thresholds entries instead",
// SchemaVersionV2Alpha1))
// }
// Require v2alpha1-specific fields
if r.RuleCondition.Thresholds == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"condition.thresholds: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else if threshold, err := r.RuleCondition.Thresholds.GetRuleThreshold(); err == nil {
if err := threshold.Validate(); err != nil {
errs = append(errs, err)
}
if thresholds, ok := threshold.(BasicRuleThresholds); ok {
if err := thresholds.validateUniqueNames(); err != nil {
errs = append(errs, err)
}
if err := thresholds.validateRecoveryTargets(); err != nil {
errs = append(errs, err)
}
}
}
if r.Evaluation == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"evaluation: field is required for schemaVersion %q", SchemaVersionV2Alpha1))
} else if evaluation, err := r.Evaluation.GetEvaluation(); err == nil {
if err := evaluation.validateDurationText(); err != nil {
errs = append(errs, err)
}
}
if r.NotificationSettings == nil {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
@@ -591,11 +487,24 @@ func (r *PostableRule) validateV2Alpha1() []error {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"notificationSettings.renotify.interval: must be a positive duration when renotify is enabled"))
}
if r.NotificationSettings.Renotify != nil {
if err := validateDurationText("notificationSettings.renotify.interval", r.NotificationSettings.Renotify.ReNotifyInterval); err != nil {
errs = append(errs, err)
}
}
}
return errs
}
// validateDurationText rejects a bare number, which decodes as nanoseconds.
func validateDurationText(field string, duration valuer.TextDuration) error {
if duration.IsZero() || duration.HasText() {
return nil
}
return errors.NewInvalidInputf(errors.CodeInvalidInput, "%s: must be a duration string such as \"5m\"", field)
}
func testTemplateParsing(rl *PostableRule) (errs []error) {
if rl.AlertName.IsZero() {
// Not an alerting rule.
@@ -656,31 +565,6 @@ type GettableRule struct {
UpdatedBy *string `json:"updateBy" nullable:"true" description:"The identifier of the user who last updated the rule."`
}
func (g *GettableRule) MarshalJSON() ([]byte, error) {
type Alias GettableRule
switch g.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *g
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *g
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}
// Rule is the v2 API read model for an alerting rule. It aligns audit fields
// with the canonical types.TimeAuditable / types.UserAuditable shape used by
// PlannedMaintenance and other entities. v1 handlers keep serializing
@@ -709,28 +593,3 @@ func NewRule(g *GettableRule) *Rule {
}
return r
}
func (r *Rule) MarshalJSON() ([]byte, error) {
type Alias Rule
switch r.SchemaVersion {
case DefaultSchemaVersion:
copyStruct := *r
aux := Alias(copyStruct)
if aux.RuleCondition != nil {
aux.RuleCondition.Thresholds = nil
}
aux.Evaluation = nil
aux.SchemaVersion = ""
aux.NotificationSettings = nil
return json.Marshal(aux)
case SchemaVersionV2Alpha1:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
default:
copyStruct := *r
aux := Alias(copyStruct)
return json.Marshal(aux)
}
}

View File

@@ -3,10 +3,8 @@ package ruletypes
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -29,8 +27,7 @@ func TestParseIntoRule(t *testing.T) {
"alertType": "METRIC_BASED_ALERT",
"description": "Test description",
"ruleType": "threshold_rule",
"evalWindow": "5m",
"frequency": "1m",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -45,11 +42,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -63,46 +60,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
{
name: "rule with default values applied",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultsRule",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test_metric", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 5.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.EvalWindow.Duration() != 5*time.Minute {
t.Errorf("Expected default eval window '5m', got '%v'", rule.EvalWindow)
}
if rule.Frequency.Duration() != time.Minute {
t.Errorf("Expected default frequency '1m', got '%v'", rule.Frequency)
}
},
},
{
@@ -110,6 +67,7 @@ func TestParseIntoRule(t *testing.T) {
initRule: PostableRule{},
content: []byte(`{
"alert": "PromQLRule",
"schemaVersion": "v2alpha1",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -123,11 +81,11 @@ func TestParseIntoRule(t *testing.T) {
}
}]
},
"target": 10.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 10.0, "matchType": "1", "op": "1"}]},
"selectedQueryName": "A"
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
@@ -138,9 +96,6 @@ func TestParseIntoRule(t *testing.T) {
if rule.RuleCondition.Thresholds.Kind.IsZero() {
t.Error("Expected thresholds to be populated")
}
if rule.RuleCondition.Target == nil {
t.Error("Expected target to be populated")
}
},
},
}
@@ -177,233 +132,6 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
expectValidateError bool // Validate() error (write path only)
validate func(*testing.T, *PostableRule)
}{
{
name: "schema v1 - threshold name from severity label",
initRule: PostableRule{},
content: []byte(`{
"alert": "SeverityLabelTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 85.0,
"targetUnit": "%",
"matchType": "1",
"op": "1"
},
"labels": {
"severity": "warning",
"team": "platform"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated for v1")
}
threshold := rule.RuleCondition.Thresholds
if threshold.Kind != BasicThresholdKind {
t.Errorf("Expected BasicThresholdKind, got %s", threshold.Kind)
}
specs, ok := threshold.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", threshold.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "warning" {
t.Errorf("Expected threshold name 'warning' from severity label, got '%s'", spec.Name)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%', got '%s'", spec.TargetUnit)
}
if *spec.TargetValue != 85.0 {
t.Errorf("Expected TargetValue 85.0, got %v", *spec.TargetValue)
}
if spec.MatchType != rule.RuleCondition.MatchType {
t.Error("Expected MatchType to be copied from RuleCondition")
}
if spec.CompareOperator != rule.RuleCondition.CompareOperator {
t.Error("Expected CompareOp to be copied from RuleCondition")
}
// Verify evaluation envelope is populated
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated for v1")
}
if rule.Evaluation.Kind != RollingEvaluation {
t.Errorf("Expected RollingEvaluation, got %s", rule.Evaluation.Kind)
}
// Verify evaluation window matches rule settings
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v1 - uses critical threshold when no labels",
initRule: PostableRule{},
content: []byte(`{
"alert": "NoLabelsTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "memory_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "1"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
spec := specs[0]
// Should default to CriticalThresholdName when no severity label
if spec.Name != CriticalThresholdName {
t.Errorf("Expected threshold name '%s', got '%s'", CriticalThresholdName, spec.Name)
}
},
},
{
name: "schema v1 - overwrites existing thresholds and evaluation",
initRule: PostableRule{},
content: []byte(`{
"alert": "OverwriteTest",
"schemaVersion": "v1",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "cpu_usage", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}],
"unit": "percent"
},
"target": 80.0,
"targetUnit": "%",
"matchType": "1",
"op": "1",
"thresholds": {
"kind": "basic",
"spec": [{
"name": "existing_threshold",
"target": 50.0,
"targetUnit": "MB",
"ruleUnit": "bytes",
"matchType": "1",
"op": "1"
}]
}
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "10m",
"frequency": "2m"
}
},
"frequency":"7m",
"evalWindow":"11m",
"labels": {
"severity": "critical"
}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.RuleCondition.Thresholds == nil {
t.Fatal("Expected Thresholds to be populated")
}
specs, ok := rule.RuleCondition.Thresholds.Spec.(BasicRuleThresholds)
if !ok {
t.Fatalf("Expected BasicRuleThresholds, got %T", rule.RuleCondition.Thresholds.Spec)
}
if len(specs) != 1 {
t.Fatalf("Expected 1 threshold spec, got %d", len(specs))
}
spec := specs[0]
if spec.Name != "critical" {
t.Errorf("Expected threshold name 'critical' (overwritten), got '%s'", spec.Name)
}
if *spec.TargetValue != 80.0 {
t.Errorf("Expected TargetValue 80.0 (overwritten), got %v", *spec.TargetValue)
}
if spec.TargetUnit != "%" {
t.Errorf("Expected TargetUnit '%%' (overwritten), got '%s'", spec.TargetUnit)
}
if rule.Evaluation == nil {
t.Fatal("Expected Evaluation to be populated")
}
if window, ok := rule.Evaluation.Spec.(RollingWindow); ok {
if !window.EvalWindow.Equal(rule.EvalWindow) {
t.Errorf("Expected Evaluation EvalWindow to be overwritten to %v, got %v", rule.EvalWindow, window.EvalWindow)
}
if !window.Frequency.Equal(rule.Frequency) {
t.Errorf("Expected Evaluation Frequency to be overwritten to %v, got %v", rule.Frequency, window.Frequency)
}
} else {
t.Errorf("Expected RollingWindow spec, got %T", rule.Evaluation.Spec)
}
},
},
{
name: "schema v2alpha1 - uses explicit thresholds and evaluation",
initRule: PostableRule{},
@@ -645,19 +373,19 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 100.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectValidateError: true,
},
{
name: "default schema version - defaults to v1 behavior",
name: "missing schema version - rejected on write",
initRule: PostableRule{},
content: []byte(`{
"alert": "DefaultSchemaTest",
"alert": "MissingSchemaTest",
"version": "v5",
"condition": {
"compositeQuery": {
@@ -672,24 +400,13 @@ func TestParseIntoRuleSchemaVersioning(t *testing.T) {
}
}]
},
"target": 75.0,
"matchType": "1",
"op": "1"
}
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 75.0, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"renotify": {"enabled": true, "interval": "4h", "alertStates": ["firing"]}}
}`),
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.SchemaVersion != DefaultSchemaVersion {
t.Errorf("Expected default schemaVersion '%s', got '%s'", DefaultSchemaVersion, rule.SchemaVersion)
}
if rule.RuleCondition.Thresholds == nil {
t.Error("Expected Thresholds to be populated for default schema version")
}
if rule.Evaluation == nil {
t.Error("Expected Evaluation to be populated for default schema version")
}
},
kind: RuleDataKindJson,
expectValidateError: true,
},
}
@@ -737,11 +454,7 @@ func TestParseIntoRuleThresholdGeneration(t *testing.T) {
}
}]
},
"target": 100.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A",
"targetUnit": "ms",
"selectedQueryName": "A",
"thresholds": {
"kind": "basic",
"spec": [
@@ -906,449 +619,3 @@ func TestParseIntoRuleMultipleThresholds(t *testing.T) {
assert.Equal(t, 1, len(vector))
}
func TestAnomalyNegationEval(t *testing.T) {
tests := []struct {
name string
ruleJSON []byte
series *qbtypes.TimeSeries
shouldAlert bool
expectedValue float64
}{
{
name: "anomaly rule with ValueIsBelow - should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // below & at least once, should alert
{Timestamp: 2000, Value: -2.3},
},
},
shouldAlert: true,
expectedValue: -2.1,
},
{
name: "anomaly rule with ValueIsBelow; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`), // below & at least once, no value below -2.0
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -1.9},
{Timestamp: 2000, Value: -1.8},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsAbove; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`), // above & at least once, should alert
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 2.1}, // above 2.0, should alert
{Timestamp: 2000, Value: 2.2},
},
},
shouldAlert: true,
expectedValue: 2.1,
},
{
name: "anomaly rule with ValueIsAbove; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyAboveTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "1",
"op": "1",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 1.1},
{Timestamp: 2000, Value: 1.2},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`), // below and all the times
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -2.1}, // all below -2
{Timestamp: 2000, Value: -2.2},
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: true,
expectedValue: -2.1, // max value when all are below threshold
},
{
name: "anomaly rule with ValueIsBelow and AllTheTimes; should not alert",
ruleJSON: []byte(`{
"alert": "AnomalyBelowAllTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 2.0,
"matchType": "2",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -3.0},
{Timestamp: 2000, Value: -1.0}, // above -2, breaks condition
{Timestamp: 3000, Value: -2.5},
},
},
shouldAlert: false,
},
{
name: "anomaly rule with ValueOutsideBounds; should alert",
ruleJSON: []byte(`{
"alert": "AnomalyOutOfBoundsTest",
"ruleType": "anomaly_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 7.0,
"matchType": "1",
"op": "7",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: -8.0}, // abs(−8) >= 7, alert
{Timestamp: 2000, Value: 5.0},
},
},
shouldAlert: true,
expectedValue: -8.0,
},
{
name: "non-anomaly threshold rule with ValueIsBelow; should alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 90.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 80.0}, // below 90, should alert
{Timestamp: 2000, Value: 85.0},
},
},
shouldAlert: true,
expectedValue: 80.0,
},
{
name: "non-anomaly rule with ValueIsBelow - should not alert",
ruleJSON: []byte(`{
"alert": "ThresholdTest",
"ruleType": "threshold_rule",
"version": "v5",
"condition": {
"compositeQuery": {
"queryType": "builder",
"queries": [{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "test", "spaceAggregation": "p50"}],
"stepInterval": "5m"
}
}]
},
"target": 50.0,
"matchType": "1",
"op": "2",
"selectedQuery": "A"
}
}`),
series: &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
},
Value: "server1",
},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1000, Value: 60.0}, // below, should alert
{Timestamp: 2000, Value: 90.0},
},
},
shouldAlert: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := PostableRule{}
err := json.Unmarshal(tt.ruleJSON, &rule)
if err != nil {
t.Fatalf("Failed to unmarshal rule: %v", err)
}
ruleThreshold, err := rule.RuleCondition.Thresholds.GetRuleThreshold()
if err != nil {
t.Fatalf("unexpected error from GetRuleThreshold: %v", err)
}
resultVector, err := ruleThreshold.Eval(tt.series, "", EvalData{})
if err != nil {
t.Fatalf("unexpected error from Eval: %v", err)
}
shouldAlert := len(resultVector) > 0
if shouldAlert != tt.shouldAlert {
t.Errorf("Expected shouldAlert=%v, got %v. %s",
tt.shouldAlert, shouldAlert, tt.name)
}
if tt.shouldAlert && len(resultVector) > 0 {
sample := resultVector[0]
if sample.V != tt.expectedValue {
t.Errorf("Expected alert value=%.2f, got %.2f. %s",
tt.expectedValue, sample.V, tt.name)
}
}
})
}
}
func TestVersionDefaultsToV5(t *testing.T) {
content := `{
"alert": "cpu high",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"schemaVersion": "v2alpha1",
"condition": {
"compositeQuery": {
"queries": [{"type": "promql", "spec": {"name": "A", "query": "up"}}],
"panelType": "graph",
"queryType": "promql"
},
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "1", "op": "1"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": false}
}`
rule := PostableRule{}
require.NoError(t, json.Unmarshal([]byte(content), &rule))
assert.Equal(t, "v5", rule.Version)
assert.NoError(t, rule.Validate())
}

View File

@@ -29,6 +29,9 @@ func (EvaluationKind) Enum() []any {
type Evaluation interface {
NextWindowFor(curr time.Time) (time.Time, time.Time)
GetFrequency() valuer.TextDuration
// validateDurationText runs on the write path only; a stored window may
// hold a bare number and still evaluate.
validateDurationText() error
}
type RollingWindow struct {
@@ -36,6 +39,13 @@ type RollingWindow struct {
Frequency valuer.TextDuration `json:"frequency" required:"true" description:"How often the rule is evaluated, as a Go duration such as 1m. Must be positive."`
}
func (rollingWindow RollingWindow) validateDurationText() error {
if err := validateDurationText("evaluation.spec.evalWindow", rollingWindow.EvalWindow); err != nil {
return err
}
return validateDurationText("evaluation.spec.frequency", rollingWindow.Frequency)
}
func (rollingWindow RollingWindow) Validate() error {
if !rollingWindow.EvalWindow.IsPositive() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "evalWindow must be greater than zero")
@@ -60,6 +70,10 @@ type CumulativeWindow struct {
Timezone string `json:"timezone" required:"true" description:"The IANA time zone the schedule is resolved in, such as UTC or Asia/Kolkata."`
}
func (cumulativeWindow CumulativeWindow) validateDurationText() error {
return validateDurationText("evaluation.spec.frequency", cumulativeWindow.Frequency)
}
type CumulativeSchedule struct {
Type ScheduleType `json:"type" required:"true" description:"How often the window resets: hourly, daily, weekly or monthly."`
Minute *int `json:"minute,omitempty" description:"The minute of the reset boundary, 0 to 59. Required for every type."`
@@ -328,6 +342,9 @@ func (e *EvaluationEnvelope) UnmarshalJSON(data []byte) error {
}
func (e *EvaluationEnvelope) GetEvaluation() (Evaluation, error) {
if e == nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "evaluation is nil")
}
if e.Kind.IsZero() {
e.Kind = RollingEvaluation
}

View File

@@ -33,6 +33,11 @@ type storedRuleData struct {
RuleType RuleType `json:"ruleType"`
Disabled bool `json:"disabled"`
Labels map[string]string `json:"labels"`
Condition struct {
CompositeQuery struct {
QueryType QueryType `json:"queryType"`
} `json:"compositeQuery"`
} `json:"condition"`
}
// ToListableRule leaves State zero; the caller overlays evaluation state.
@@ -42,6 +47,15 @@ func (rule *StorableRule) ToListableRule() (*ListableRule, error) {
return nil, err
}
switch data.Condition.CompositeQuery.QueryType {
case QueryTypeBuilder, QueryTypeClickHouseSQL:
if data.RuleType.IsZero() {
data.RuleType = RuleTypeThreshold
}
case QueryTypePromQL:
data.RuleType = RuleTypeProm
}
return &ListableRule{
Id: rule.ID.StringValue(),
AlertName: data.AlertName,

View File

@@ -147,7 +147,7 @@ func TestV2MinimalReadShape(t *testing.T) {
body := readBody(t, stored)
for _, field := range []string{"labels", "annotations", "description", "preferredChannels", "evalWindow", "frequency"} {
for _, field := range []string{"labels", "annotations", "description"} {
assert.NotContains(t, body, field)
}
@@ -159,7 +159,7 @@ func TestV2MinimalReadShape(t *testing.T) {
assert.JSONEq(t, `false`, string(ns["usePolicy"]))
assert.JSONEq(t, `false`, string(body["disabled"]))
assert.JSONEq(t, `"v5"`, string(body["version"]))
assert.JSONEq(t, `""`, string(body["version"]))
var condition struct {
Thresholds struct {
@@ -297,6 +297,7 @@ func TestPatchMergePreservesUnpatchedFields(t *testing.T) {
"panelType": "graph",
"queryType": "promql"
},
"selectedQueryName": "A",
"thresholds": {"kind": "basic", "spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above"}]}
},
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},

View File

@@ -85,9 +85,6 @@ func (r *RuleThresholdData) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(raw["spec"], &basicThresholds); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to unmarshal rule threshold spec: %v", err)
}
if err := basicThresholds.Validate(); err != nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
}
r.Spec = basicThresholds
default:
@@ -130,6 +127,9 @@ type RuleThreshold interface {
// using the given EvalData and returns the matching series
Eval(series *qbtypes.TimeSeries, unit string, evalData EvalData) (Vector, error)
GetRuleReceivers() []RuleReceivers
// Validate runs on the write path and before a task is built; a stored
// rule may hold a threshold that fails it and still decode.
Validate() error
}
type BasicRuleThreshold struct {
@@ -170,6 +170,37 @@ func (r BasicRuleThresholds) Validate() error {
return errors.Join(errs...)
}
func (r BasicRuleThresholds) validateUniqueNames() error {
names := make(map[string]struct{}, len(r))
for _, basicThreshold := range r {
if _, seen := names[basicThreshold.Name]; seen {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: duplicate threshold name %q", basicThreshold.Name)
}
names[basicThreshold.Name] = struct{}{}
}
return nil
}
func (r BasicRuleThresholds) validateRecoveryTargets() error {
for _, basicThreshold := range r {
if basicThreshold.RecoveryTarget == nil || basicThreshold.TargetValue == nil {
continue
}
// equal, not_equal and outside_bounds have no recovering side
switch basicThreshold.CompareOperator.Normalize() {
case ValueIsAbove, ValueAboveOrEq:
if *basicThreshold.RecoveryTarget >= *basicThreshold.TargetValue {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: recoveryTarget must be below target for op %s", basicThreshold.CompareOperator.StringValue())
}
case ValueIsBelow, ValueBelowOrEq:
if *basicThreshold.RecoveryTarget <= *basicThreshold.TargetValue {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "condition.thresholds.spec: recoveryTarget must be above target for op %s", basicThreshold.CompareOperator.StringValue())
}
}
}
return nil
}
func (r BasicRuleThresholds) Eval(s *qbtypes.TimeSeries, unit string, evalData EvalData) (Vector, error) {
series := &qbtypes.TimeSeries{

File diff suppressed because it is too large Load Diff

View File

@@ -54,6 +54,12 @@ func (d TextDuration) IsPositive() bool {
return d.value > 0
}
// HasText reports whether the duration came as text; a bare JSON number
// decodes to nanoseconds and keeps none.
func (d TextDuration) HasText() bool {
return d.text != ""
}
// String implements the [fmt.Stringer] interface.
func (d TextDuration) String() string {
if len(d.text) > 0 {

481
tests/e2e/drivers/saved-views.ts generated Normal file
View File

@@ -0,0 +1,481 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import {
expect,
type Locator,
type Page,
type Request,
type Response,
} from '@playwright/test';
import { BASE_PATH } from './base-path';
import { collectRequests } from './console';
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
import {
filterExpressions,
isQueryRangeRequest,
queryRangeBody,
} from './saved-views/query';
import { SAVED_VIEWS_API, seededView } from './saved-views/spec';
import type {
GettableSavedView,
PostableSavedView,
SavedViewSource,
SeededView,
} from './saved-views/spec.types';
import { driver, kit } from './kit';
import {
createViaApi,
getViaApi,
removeTrackedSavedViews,
statusViaApi,
trackSavedView,
} from './saved-views/api';
// The saved views header and list on the explorers: the sliding list on logs,
// traces and meter, the pinned one on metrics.
export const MY_VIEW = 'My view';
const EXPLORER_PATH: Record<SavedViewSource, string> = {
logs: '/logs/logs-explorer',
traces: '/traces-explorer',
metrics: '/metrics-explorer/explorer',
meter: '/meter/explorer',
};
export function explorerPathname(source: SavedViewSource): string {
return `${BASE_PATH}${EXPLORER_PATH[source]}`;
}
/** Row-menu items by the suffix of their `saved-views-row-menu-item-<key>` test id. */
export type RowAction = 'copyLink' | 'copyId' | 'editDetails' | 'delete';
/** Save-menu items by the suffix of their `saved-views-save-changes-item-<key>` test id. */
export type SaveAction = 'update' | 'save-as-new';
export type LinkGesture = 'modifier' | 'middle';
export interface OpenedTab {
page: Page;
savedViews: SavedViews;
}
export type ExplorerTab = 'list' | 'timeseries' | 'table';
export interface SavedViews {
/** Created over the page's own session; deleted when the test ends. */
seed(view: PostableSavedView): Promise<SeededView>;
/** The stored view, read straight from the API. */
get(id: string): Promise<GettableSavedView>;
/** The status of `GET /api/v2/saved_views/<id>`, for asserting a view is gone. */
status(id: string): Promise<number>;
/** On a bare url by default, which is `relativeTime=1h` and nothing else. */
openExplorer(
source?: SavedViewSource,
params?: Record<string, string>,
): Promise<void>;
/** Reloads and waits for the explorer to send the filter's query. */
reload(filter?: string, requestType?: string): Promise<void>;
/** The explorer's next query that carries `filter`, of `requestType` when given. */
queryRan(filter: string, requestType?: string): Promise<Response>;
/** The current url. */
url(): URL;
/** A query param of the current url, where the open view and time range live. */
param(name: string): string | null;
/** The id in the `viewKey` param, or null while no view is open. */
activeViewId(): string | null;
header(): Locator;
name(): Locator;
createButton(): Locator;
clearButton(): Locator;
discardButton(): Locator;
saveChangesButton(): Locator;
saveAction(action: SaveAction): Locator;
/** The view is open and nothing differs from what is stored. */
expectClean(displayName: string): Promise<void>;
/** The view is open and the explorer holds changes to it. */
expectDirty(displayName: string): Promise<void>;
/** No view is open. */
expectNoView(): Promise<void>;
toast(text: string): Locator;
openListButton(): Locator;
panel(): Locator;
searchInput(): Locator;
openList(): Promise<void>;
/** Narrows the list, and waits for exactly `rows` rows when given. */
search(text: string, rows?: number): Promise<void>;
rows(): Locator;
row(id: string): Locator;
rowLink(id: string): Locator;
/** The row link's href, resolved against the current page. */
rowHref(id: string): Promise<URL>;
/**
* Searches for the view, opens it from its row and waits for the explorer's
* query for it. Resolves with that query's response.
*/
open(view: SeededView): Promise<Response>;
rowMenuButton(id: string): Locator;
rowAction(action: RowAction): Locator;
openRowMenu(id: string): Promise<void>;
/** Opens the row's menu and picks an action once its permission check settles. */
chooseRowAction(id: string, action: RowAction): Promise<void>;
/** Opens the row's menu and picks Copy link, returning the copied text. */
copyLink(id: string): Promise<string>;
/**
* Ctrl or Cmd click, or a middle click, on the row link. Resolves once the
* tab exists, not once it has loaded; the driver is bound to the new tab.
*/
openInNewTab(id: string, gesture?: LinkGesture): Promise<OpenedTab>;
/**
* Opens `url` in a new tab of the same browser context and waits for the
* explorer's query for `view`.
*/
openLink(url: URL | string, view: SeededView): Promise<OpenedTab>;
/** Tabs the browser context has opened since this driver was built. */
newTabCount(): number;
rename(id: string, displayName: string): Promise<void>;
/** Confirms the delete dialog and waits for the DELETE. */
deleteView(id: string): Promise<void>;
modal(): Locator;
modalNameInput(): Locator;
modalSubmit(): Locator;
deleteConfirm(): Locator;
/** Create from the header: names the view, saves it and returns its id. */
createFromHeader(displayName: string): Promise<string>;
/** Types into the query bar, runs it and waits for the query to carry it. */
applyFilter(filter: string): Promise<void>;
queryBar(): Locator;
/** Header cells of the logs table with a title: the state indicator has none. */
logsColumnHeaders(): Locator;
openLogsFormatMenu(): Promise<void>;
closeLogsFormatMenu(): Promise<void>;
/** The checked format in the open menu: Raw, Default or Column. */
selectedLogsFormat(): Locator;
logsMaxLines(): Locator;
logsFontSize(): Locator;
explorerTab(tab: ExplorerTab): Locator;
/** Changes when the document reloads and not when the app navigates in place. */
documentId(): Promise<number>;
/** Update selected view, waiting for the PUT and the toast. */
update(): Promise<void>;
discard(): Promise<void>;
/** `GET /api/v2/saved_views/<id>` requests the page has made so far. */
viewGets(id: string): Request[];
}
/** The driver on any page of the same context, such as a tab a link opened. */
export function savedViewsFor(page: Page): SavedViews {
const requests = collectRequests(page);
let newTabs = 0;
page.context().on('page', () => {
newTabs += 1;
});
const tid = (id: string): Locator => page.getByTestId(id);
const formatMenu = (): Locator => page.locator('.format-options-popover');
const viewPath = (id: string): string =>
`${BASE_PATH}${SAVED_VIEWS_API}/${id}`;
// A menu row, like a header button, ignores clicks until its permission
// check settles, and stays enabled-looking meanwhile.
const settle = async (control: Locator): Promise<void> => {
await expect(control).toBeVisible();
await expect(control).not.toHaveAttribute('aria-busy');
await expect(control).not.toHaveAttribute('data-loading');
await expect(control).not.toHaveAttribute('data-disabled');
};
const press = async (control: Locator): Promise<void> => {
await settle(control);
await control.click();
};
const savedViews: SavedViews = {
async seed(view) {
const id = await createViaApi(page, view);
trackSavedView(page, id);
return seededView(id, view);
},
get: (id) => getViaApi(page, id),
status: (id) => statusViaApi(page, id),
async openExplorer(source = 'logs', params = { relativeTime: '1h' }) {
await dismissQuickFiltersAnnouncement(page);
const search = new URLSearchParams(params).toString();
await page.goto(`${EXPLORER_PATH[source]}${search ? `?${search}` : ''}`);
await expect(savedViews.header()).toBeVisible();
},
async reload(filter, requestType) {
await Promise.all([
filter === undefined ? undefined : savedViews.queryRan(filter, requestType),
page.reload(),
]);
await expect(savedViews.header()).toBeVisible();
},
queryRan: (filter, requestType) =>
page.waitForResponse((res) => {
const request = res.request();
return (
isQueryRangeRequest(request) &&
filterExpressions(request).includes(filter) &&
(requestType === undefined ||
queryRangeBody(request).requestType === requestType)
);
}),
url: () => new URL(page.url()),
param: (name) => new URL(page.url()).searchParams.get(name),
activeViewId() {
const raw = savedViews.param('viewKey');
return raw === null ? null : (JSON.parse(raw) as string);
},
header: () => tid('saved-views-header'),
name: () => tid('saved-views-name'),
createButton: () => tid('saved-views-create'),
clearButton: () => tid('saved-views-clear'),
discardButton: () => tid('saved-views-discard'),
saveChangesButton: () => tid('saved-views-save-changes'),
saveAction: (action) => tid(`saved-views-save-changes-item-${action}`),
async expectClean(displayName) {
await expect(savedViews.name()).toHaveText(displayName);
await expect(savedViews.clearButton()).toBeVisible();
await expect(savedViews.discardButton()).toBeHidden();
await expect(savedViews.saveChangesButton()).toBeHidden();
},
async expectDirty(displayName) {
await expect(savedViews.name()).toHaveText(displayName);
await expect(savedViews.discardButton()).toBeVisible();
await expect(savedViews.saveChangesButton()).toBeVisible();
await expect(savedViews.clearButton()).toBeHidden();
},
async expectNoView() {
await expect(savedViews.name()).toHaveText(MY_VIEW);
await expect(savedViews.createButton()).toBeVisible();
await expect(savedViews.clearButton()).toBeHidden();
await expect.poll(() => savedViews.param('viewKey')).toBeNull();
},
toast: (text) =>
page.locator('[data-sonner-toast]').filter({ hasText: text }),
openListButton: () => tid('saved-views-open'),
panel: () => tid('saved-views-panel'),
searchInput: () => tid('saved-views-search'),
async openList() {
await expect(
savedViews.openListButton().or(savedViews.panel()),
).toBeVisible();
if (!(await savedViews.panel().isVisible())) {
await press(savedViews.openListButton());
}
await expect(savedViews.panel()).toBeVisible();
},
async search(text, rows) {
await savedViews.searchInput().fill(text);
if (rows !== undefined) {
await expect(savedViews.rows()).toHaveCount(rows);
}
},
rows: () => tid('saved-views-row'),
row: (id) =>
page.locator(`[data-testid="saved-views-row"][data-view-id="${id}"]`),
rowLink: (id) => savedViews.row(id).getByTestId('saved-views-row-name'),
async rowHref(id) {
const href = await savedViews.rowLink(id).getAttribute('href');
expect(href, `row ${id} has no href`).not.toBeNull();
return new URL(href as string, page.url());
},
async open(view) {
await savedViews.openList();
await savedViews.search(view.displayName);
const [response] = await Promise.all([
savedViews.queryRan(view.filter, view.requestType),
savedViews.rowLink(view.id).click(),
]);
await expect.poll(() => savedViews.activeViewId()).toBe(view.id);
await expect(savedViews.name()).toHaveText(view.displayName);
return response;
},
rowMenuButton: (id) => savedViews.row(id).getByTestId('saved-views-row-menu'),
rowAction: (action) => tid(`saved-views-row-menu-item-${action}`),
async openRowMenu(id) {
// The menu button has no width until the row is hovered.
await savedViews.row(id).hover();
await savedViews.rowMenuButton(id).click();
},
async chooseRowAction(id, action) {
await savedViews.openRowMenu(id);
await press(savedViews.rowAction(action));
},
async copyLink(id) {
// Clipboard permissions are Chromium-only, so the copy is caught in the
// page: react-use copies through a selection and execCommand, whose
// `copy` event a capture listener sees first.
await page.evaluate(() => {
const w = window as unknown as { __copied?: string };
delete w.__copied;
document.addEventListener(
'copy',
() => {
w.__copied = document.getSelection()?.toString() ?? '';
},
{ capture: true, once: true },
);
});
await savedViews.chooseRowAction(id, 'copyLink');
const copied = (): Promise<string | undefined> =>
page.evaluate(() => (window as unknown as { __copied?: string }).__copied);
await expect.poll(copied).toBeTruthy();
return (await copied()) as string;
},
async openInNewTab(id, gesture = 'modifier') {
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
savedViews
.rowLink(id)
.click(
gesture === 'modifier'
? { modifiers: ['ControlOrMeta'] }
: { button: 'middle' },
),
]);
return { page: tab, savedViews: savedViewsFor(tab) };
},
async openLink(url, view) {
const tab = await page.context().newPage();
const opened = savedViewsFor(tab);
await Promise.all([
opened.queryRan(view.filter, view.requestType),
tab.goto(url.toString()),
]);
return { page: tab, savedViews: opened };
},
newTabCount: () => newTabs,
async rename(id, displayName) {
await savedViews.chooseRowAction(id, 'editDetails');
await expect(savedViews.modalNameInput()).toBeVisible();
await savedViews.modalNameInput().fill(displayName);
const [response] = await Promise.all([
page.waitForResponse(
(res) =>
res.request().method() === 'PUT' &&
new URL(res.url()).pathname === viewPath(id),
),
savedViews.modalSubmit().click(),
]);
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
await expect(savedViews.modal()).toBeHidden();
},
async deleteView(id) {
await Promise.all([
page.waitForResponse(
(res) =>
res.request().method() === 'DELETE' &&
new URL(res.url()).pathname === viewPath(id),
),
press(savedViews.deleteConfirm()),
]);
await expect(savedViews.deleteConfirm()).toBeHidden();
},
modal: () => tid('save-view-modal'),
modalNameInput: () => tid('save-view-name'),
modalSubmit: () => tid('save-view-submit'),
deleteConfirm: () => tid('delete-saved-view-confirm'),
async createFromHeader(displayName) {
await press(savedViews.createButton());
await savedViews.modalNameInput().fill(displayName);
const [response] = await Promise.all([
page.waitForResponse(
(res) =>
res.request().method() === 'POST' &&
new URL(res.url()).pathname === `${BASE_PATH}${SAVED_VIEWS_API}`,
),
savedViews.modalSubmit().click(),
]);
expect(response.status(), `POST ${SAVED_VIEWS_API}`).toBe(201);
const { id } = ((await response.json()) as { data: { id: string } }).data;
trackSavedView(page, id);
await expect(savedViews.modal()).toBeHidden();
return id;
},
logsColumnHeaders: () => page.locator('thead th').filter({ hasText: /\S/ }),
async openLogsFormatMenu() {
await tid('periscope-btn-format-options').click();
await expect(formatMenu()).toBeVisible();
},
async closeLogsFormatMenu() {
await tid('periscope-btn-format-options').click();
await expect(formatMenu()).toBeHidden();
},
selectedLogsFormat: () =>
formatMenu()
.locator('.menu-items .item')
.filter({ has: page.locator('svg') }),
logsMaxLines: () => formatMenu().getByTestId('max-lines-per-row'),
logsFontSize: () => formatMenu().getByTestId('font-size-select'),
explorerTab: (tab) => page.locator(`.${tab}-view-tab`),
documentId: () => page.evaluate(() => performance.timeOrigin),
queryBar: () =>
page.locator('.query-where-clause-editor .cm-content').first(),
async applyFilter(filter) {
await Promise.all([
savedViews.queryRan(filter),
(async (): Promise<void> => {
await savedViews.queryBar().click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.insertText(filter);
await expect(savedViews.queryBar()).toHaveText(filter);
await page.locator('[data-run-query-btn]').click();
})(),
]);
},
async update() {
const id = savedViews.activeViewId();
await savedViews.saveChangesButton().click();
const [response] = await Promise.all([
page.waitForResponse(
(res) =>
res.request().method() === 'PUT' &&
new URL(res.url()).pathname === viewPath(id as string),
),
press(savedViews.saveAction('update')),
]);
expect(response.ok(), `PUT ${SAVED_VIEWS_API}/${id}`).toBe(true);
await expect(savedViews.toast('View updated')).toBeVisible();
},
async discard() {
await press(savedViews.discardButton());
},
viewGets: (id) =>
requests.filter(
(request) =>
request.method() === 'GET' &&
new URL(request.url()).pathname === viewPath(id),
),
};
return savedViews;
}
const withSavedViews = driver(
'savedViews',
({ page }) => savedViewsFor(page),
(_, { page }) => removeTrackedSavedViews(page),
);
export const test = withSavedViews(kit);
export { expect } from './kit';
export * from './saved-views/spec';
export type * from './saved-views/spec.types';
export { queryRangeBody } from './saved-views/query';

78
tests/e2e/drivers/saved-views/api.ts generated Normal file
View File

@@ -0,0 +1,78 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Page } from '@playwright/test';
import { authToken } from '../api';
import { SAVED_VIEWS_API } from './spec';
import type { GettableSavedView, PostableSavedView } from './spec.types';
async function bearer(page: Page): Promise<{ Authorization: string }> {
return { Authorization: `Bearer ${await authToken(page)}` };
}
export async function createViaApi(
page: Page,
view: PostableSavedView,
): Promise<string> {
const res = await page.request.post(SAVED_VIEWS_API, {
data: view,
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`POST ${SAVED_VIEWS_API} ${res.status()}: ${await res.text()}`,
);
}
return ((await res.json()) as { data: { id: string } }).data.id;
}
export async function getViaApi(
page: Page,
id: string,
): Promise<GettableSavedView> {
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`GET ${SAVED_VIEWS_API}/${id} ${res.status()}: ${await res.text()}`,
);
}
return ((await res.json()) as { data: GettableSavedView }).data;
}
/** The status alone, for asserting a view is gone. */
export async function statusViaApi(page: Page, id: string): Promise<number> {
const res = await page.request.get(`${SAVED_VIEWS_API}/${id}`, {
headers: await bearer(page),
});
return res.status();
}
/** Not asserted: a test that deleted its own view must not fail on cleanup. */
export async function deleteViaApi(page: Page, id: string): Promise<void> {
try {
await page.request.delete(`${SAVED_VIEWS_API}/${id}`, {
headers: await bearer(page),
});
} catch {
/* the view outlives the run rather than hiding why it failed */
}
}
const trackedByPage = new WeakMap<Page, string[]>();
/** Registers a view for deletion when the test owning `page` ends. */
export function trackSavedView(page: Page, id: string): void {
trackedByPage.set(page, [...(trackedByPage.get(page) ?? []), id]);
}
export async function removeTrackedSavedViews(page: Page): Promise<void> {
const ids = trackedByPage.get(page) ?? [];
trackedByPage.delete(page);
for (const id of ids) {
await deleteViaApi(page, id);
}
}

40
tests/e2e/drivers/saved-views/query.ts generated Normal file
View File

@@ -0,0 +1,40 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Request } from '@playwright/test';
import { isQueryRange } from '../query-range-mock';
interface QueryEnvelope {
type: string;
spec: {
name?: string;
signal?: string;
filter?: { expression?: string };
aggregations?: { expression?: string }[];
groupBy?: { name: string }[];
};
}
export interface QueryRangeRequest {
start: number;
end: number;
requestType: string;
compositeQuery: { queries: QueryEnvelope[] };
}
export function isQueryRangeRequest(request: Request): boolean {
return request.method() === 'POST' && isQueryRange(request.url());
}
export function queryRangeBody(request: Request): QueryRangeRequest {
return request.postDataJSON() as QueryRangeRequest;
}
/** The filter of every query in the request, in order. */
export function filterExpressions(request: Request): string[] {
return queryRangeBody(request).compositeQuery.queries.map(
(query) => query.spec.filter?.expression ?? '',
);
}

121
tests/e2e/drivers/saved-views/spec.ts generated Normal file
View File

@@ -0,0 +1,121 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
// Typed builders for the saved view spec: pure data, no Playwright. The types
// live in `spec.types.ts`.
//
// `/api/v2/saved_views`: create and update take the whole spec, and an update
// replaces it, so a field left out is reset.
import { randomBytes } from 'crypto';
import type {
LogsViewOptions,
PostableSavedView,
SavedViewColumn,
SavedViewPanelType,
SavedViewRequestType,
SeededView,
} from './spec.types';
export const SAVED_VIEWS_API = '/api/v2/saved_views';
const REQUEST_TYPE: Record<SavedViewPanelType, SavedViewRequestType> = {
list: 'raw',
graph: 'time_series',
table: 'scalar',
};
/** Workers and browser projects share one org, so every view name is unique. */
export function uniqueViewName(label: string): string {
return `e2e sv ${label} ${randomBytes(4).toString('hex')}`;
}
export const LOG_COLUMNS: Record<string, SavedViewColumn> = {
timestamp: {
name: 'timestamp',
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
},
body: {
name: 'body',
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
},
serviceName: {
name: 'service.name',
signal: 'logs',
fieldContext: 'resource',
fieldDataType: 'string',
},
severityText: {
name: 'severity_text',
signal: 'logs',
fieldContext: 'log',
fieldDataType: 'string',
},
};
export function logsView({
displayName,
filter = '',
panelType = 'list',
groupBy = [],
columns = [LOG_COLUMNS.timestamp, LOG_COLUMNS.body],
display,
}: LogsViewOptions): PostableSavedView {
const isList = panelType === 'list';
return {
generateName: true,
source: 'logs',
schemaVersion: 'v2',
spec: {
displayName,
panelType,
requestType: REQUEST_TYPE[panelType],
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
disabled: false,
filter: { expression: filter },
...(isList
? {
order: [
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
],
}
: {
aggregations: [{ expression: 'count()' }],
groupBy: groupBy.map((name) => ({
name,
fieldContext: 'resource',
fieldDataType: 'string',
})),
}),
},
},
],
selectedFields: isList ? columns : [],
...(display && { display }),
},
};
}
export function seededView(id: string, view: PostableSavedView): SeededView {
const [query] = view.spec.queries as {
spec?: { filter?: { expression?: string } };
}[];
return {
id,
displayName: view.spec.displayName,
filter: query?.spec?.filter?.expression ?? '',
requestType: view.spec.requestType,
};
}

View File

@@ -0,0 +1,69 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
// The saved view spec of `/api/v2/saved_views`.
export type SavedViewSource = 'logs' | 'traces' | 'metrics' | 'meter';
export type SavedViewPanelType = 'list' | 'graph' | 'table';
export type SavedViewRequestType = 'raw' | 'time_series' | 'scalar';
export interface SavedViewColumn {
name: string;
signal?: string;
fieldContext?: string;
fieldDataType?: string;
}
export interface SavedViewDisplay {
format?: string;
maxLines?: number;
fontSize?: string;
color?: string;
}
export interface SavedViewSpec {
displayName: string;
panelType: SavedViewPanelType;
requestType: SavedViewRequestType;
queries: Record<string, unknown>[];
selectedFields?: SavedViewColumn[];
display?: SavedViewDisplay;
}
export interface PostableSavedView {
generateName: true;
source: SavedViewSource;
schemaVersion: 'v2';
spec: SavedViewSpec;
}
export interface GettableSavedView {
id: string;
name: string;
source: SavedViewSource;
createdBy: string;
updatedBy?: string;
spec: SavedViewSpec;
}
export interface LogsViewOptions {
displayName: string;
filter?: string;
panelType?: SavedViewPanelType;
/** Group by attributes of a graph or table view, as resource attributes. */
groupBy?: string[];
/** Defaults to timestamp and body, which a list view always shows. */
columns?: SavedViewColumn[];
display?: SavedViewDisplay;
}
export interface SeededView {
id: string;
displayName: string;
/** The first query's filter, which the explorer sends once the view is open. */
filter: string;
/** What the explorer's own query for the view asks for: raw, time_series or scalar. */
requestType: string;
}

View File

@@ -0,0 +1,176 @@
import {
expect,
LOG_COLUMNS,
logsView,
test,
uniqueViewName,
} from '../../drivers/saved-views';
const SAVED_FILTER = "service.name = 'frontend'";
const EDITED_FILTER = "service.name = 'cartservice'";
test.describe('Saved views: lifecycle on the logs explorer', () => {
test('TC-01 a view created from the header survives a reload clean', async ({
savedViews,
}) => {
const displayName = uniqueViewName('create');
await savedViews.openExplorer();
await savedViews.expectNoView();
await savedViews.applyFilter(SAVED_FILTER);
const id = await savedViews.createFromHeader(displayName);
await expect(savedViews.toast('View created')).toBeVisible();
await expect.poll(() => savedViews.activeViewId()).toBe(id);
await savedViews.expectClean(displayName);
const stored = await savedViews.get(id);
expect(stored.source).toBe('logs');
expect(stored.spec.displayName).toBe(displayName);
expect(stored.spec.panelType).toBe('list');
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
await savedViews.reload(SAVED_FILTER);
await savedViews.expectClean(displayName);
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
await savedViews.openList();
await savedViews.search(displayName);
await expect(savedViews.row(id)).toBeVisible();
await expect(savedViews.rowLink(id)).toHaveAttribute('aria-current', 'page');
});
test('TC-02 an edited view turns dirty, updates, and opens with the new filter after a reload', async ({
savedViews,
}) => {
const view = await savedViews.seed(
logsView({
displayName: uniqueViewName('update'),
filter: SAVED_FILTER,
}),
);
await savedViews.openExplorer();
await savedViews.open(view);
await savedViews.expectClean(view.displayName);
await savedViews.applyFilter(EDITED_FILTER);
await savedViews.expectDirty(view.displayName);
await savedViews.update();
await savedViews.expectClean(view.displayName);
const stored = await savedViews.get(view.id);
expect(JSON.stringify(stored.spec.queries)).toContain(EDITED_FILTER);
expect(JSON.stringify(stored.spec.queries)).not.toContain(SAVED_FILTER);
await savedViews.reload(EDITED_FILTER);
await savedViews.expectClean(view.displayName);
await expect(savedViews.queryBar()).toHaveText(EDITED_FILTER);
});
test('TC-03 discarding changes brings back the stored filter and tab', async ({
savedViews,
}) => {
const view = await savedViews.seed(
logsView({
displayName: uniqueViewName('discard'),
filter: SAVED_FILTER,
}),
);
await savedViews.openExplorer();
await savedViews.open(view);
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
await savedViews.applyFilter(EDITED_FILTER);
await savedViews.explorerTab('timeseries').click();
await expect(savedViews.explorerTab('timeseries')).toHaveClass(/active-tab/);
await savedViews.expectDirty(view.displayName);
await savedViews.discard();
await savedViews.expectClean(view.displayName);
await expect(savedViews.queryBar()).toHaveText(SAVED_FILTER);
await expect(savedViews.explorerTab('list')).toHaveClass(/active-tab/);
expect(savedViews.param('panelTypes')).toBe('"list"');
const stored = await savedViews.get(view.id);
expect(JSON.stringify(stored.spec.queries)).toContain(SAVED_FILTER);
});
test('TC-04 deleting the open view clears it without trying to load it again', async ({
savedViews,
}) => {
const view = await savedViews.seed(
logsView({
displayName: uniqueViewName('delete'),
filter: SAVED_FILTER,
}),
);
await savedViews.openExplorer();
await savedViews.open(view);
await savedViews.chooseRowAction(view.id, 'delete');
const getsBeforeDelete = savedViews.viewGets(view.id).length;
expect(getsBeforeDelete).toBeGreaterThan(0);
await savedViews.deleteView(view.id);
await expect(savedViews.toast('View deleted')).toBeVisible();
await expect(savedViews.row(view.id)).toHaveCount(0);
await savedViews.expectNoView();
await expect.poll(() => savedViews.param('panelTypes')).toBeNull();
expect(savedViews.param('viewName')).toBeNull();
expect(savedViews.param('compositeQuery') ?? '').not.toContain('frontend');
expect(savedViews.viewGets(view.id)).toHaveLength(getsBeforeDelete);
expect(await savedViews.status(view.id)).toBe(404);
});
test('TC-05 renaming a view keeps its columns and display settings', async ({
savedViews,
}) => {
const columns = [
LOG_COLUMNS.timestamp,
LOG_COLUMNS.body,
LOG_COLUMNS.serviceName,
LOG_COLUMNS.severityText,
];
const headers = ['Timestamp', 'Body', 'service.name', 'severity_text'];
const display = { format: 'table', maxLines: 3, fontSize: 'medium' };
const view = await savedViews.seed(
logsView({
displayName: uniqueViewName('rename'),
filter: SAVED_FILTER,
columns,
display,
}),
);
const renamed = uniqueViewName('renamed');
const expectDisplay = async (): Promise<void> => {
await expect(savedViews.logsColumnHeaders()).toHaveText(headers);
await savedViews.openLogsFormatMenu();
await expect(savedViews.selectedLogsFormat()).toHaveText('Column');
await expect(savedViews.logsMaxLines()).toHaveValue('3');
await expect(savedViews.logsFontSize()).toHaveText('medium');
await savedViews.closeLogsFormatMenu();
};
await savedViews.openExplorer('logs', { relativeTime: '1d' });
await savedViews.open(view);
await expectDisplay();
await savedViews.openList();
await savedViews.search(view.displayName);
await savedViews.rename(view.id, renamed);
await expect(savedViews.toast('View updated')).toBeVisible();
await expect(savedViews.name()).toHaveText(renamed);
const stored = await savedViews.get(view.id);
expect(stored.spec.displayName).toBe(renamed);
expect(stored.spec.selectedFields?.map((column) => column.name)).toEqual(
columns.map((column) => column.name),
);
expect(stored.spec.display).toMatchObject(display);
await savedViews.reload(SAVED_FILTER);
await savedViews.expectClean(renamed);
await expectDisplay();
});
});

View File

@@ -0,0 +1,79 @@
import {
type ExplorerTab,
expect,
logsView,
queryRangeBody,
type SavedViewPanelType,
test,
uniqueViewName,
} from '../../drivers/saved-views';
const FILTER = "service.name = 'frontend'";
const PANELS: {
panelType: SavedViewPanelType;
tab: ExplorerTab;
requestType: string;
aggregation: string | undefined;
groupBy: string[] | undefined;
}[] = [
{
panelType: 'list',
tab: 'list',
requestType: 'raw',
aggregation: undefined,
groupBy: undefined,
},
{
panelType: 'graph',
tab: 'timeseries',
requestType: 'time_series',
aggregation: 'count()',
groupBy: ['service.name'],
},
{
panelType: 'table',
tab: 'table',
requestType: 'scalar',
aggregation: 'count()',
groupBy: ['service.name'],
},
];
test.describe('Saved views: opening from the list', () => {
for (const [
index,
{ panelType, tab, requestType, aggregation, groupBy },
] of PANELS.entries()) {
test(`TC-0${index + 1} a ${panelType} view opens with its query, tab and a clean header`, async ({
savedViews,
}) => {
const view = await savedViews.seed(
logsView({
displayName: uniqueViewName(`open ${panelType}`),
filter: FILTER,
panelType,
groupBy,
}),
);
await savedViews.openExplorer();
const response = await savedViews.open(view);
expect(savedViews.param('panelTypes')).toBe(JSON.stringify(panelType));
await expect(savedViews.explorerTab(tab)).toHaveClass(/active-tab/);
await expect(savedViews.queryBar()).toHaveText(FILTER);
await expect(savedViews.rowLink(view.id)).toHaveAttribute(
'aria-current',
'page',
);
await savedViews.expectClean(view.displayName);
const body = queryRangeBody(response.request());
expect(body.requestType).toBe(requestType);
const [query] = body.compositeQuery.queries;
expect(query.spec.filter?.expression).toBe(FILTER);
expect(query.spec.aggregations?.[0]?.expression).toBe(aggregation);
expect(query.spec.groupBy?.map((group) => group.name)).toEqual(groupBy);
});
}
});

View File

@@ -0,0 +1,169 @@
import {
expect,
explorerPathname,
logsView,
type SavedViews,
test,
uniqueViewName,
} from '../../drivers/saved-views';
const LOGS_PATH = explorerPathname('logs');
const FRONTEND_FILTER = "service.name = 'frontend'";
const CART_FILTER = "service.name = 'cartservice'";
const HOUR_MS = 3_600_000;
/** Whole milliseconds, as the links carry them. */
function customRange(): { startTime: string; endTime: string } {
const end = Math.floor(Date.now() / 60_000) * 60_000;
return {
startTime: String(end - 2 * HOUR_MS),
endTime: String(end - HOUR_MS),
};
}
async function seedTwoViews(savedViews: SavedViews) {
const target = await savedViews.seed(
logsView({ displayName: uniqueViewName('link'), filter: FRONTEND_FILTER }),
);
const other = await savedViews.seed(
logsView({ displayName: uniqueViewName('other'), filter: CART_FILTER }),
);
return { target, other };
}
test.describe('Saved views: row links', () => {
test('TC-01 a row links to its view in the selected relative time range', async ({
savedViews,
}) => {
const { target } = await seedTwoViews(savedViews);
await savedViews.openExplorer('logs', { relativeTime: '1h' });
await savedViews.openList();
await savedViews.search(target.displayName);
const href = await savedViews.rowHref(target.id);
expect(href.pathname).toBe(LOGS_PATH);
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
expect(href.searchParams.get('panelTypes')).toBe('"list"');
expect(href.searchParams.get('relativeTime')).toBe('1h');
expect(href.searchParams.has('startTime')).toBe(false);
expect(href.searchParams.has('endTime')).toBe(false);
expect(href.searchParams.get('compositeQuery')).toContain(
encodeURIComponent(FRONTEND_FILTER),
);
});
test('TC-02 a row links to its view in a custom time range, to the millisecond', async ({
savedViews,
}) => {
const { target } = await seedTwoViews(savedViews);
const range = customRange();
await savedViews.openExplorer('logs', range);
await savedViews.openList();
await savedViews.search(target.displayName);
const href = await savedViews.rowHref(target.id);
expect(href.searchParams.get('startTime')).toBe(range.startTime);
expect(href.searchParams.get('endTime')).toBe(range.endTime);
expect(href.searchParams.has('relativeTime')).toBe(false);
expect(href.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
});
test('TC-03 a plain click opens the view in place and marks its row', async ({
savedViews,
}) => {
const { target, other } = await seedTwoViews(savedViews);
await savedViews.openExplorer('logs', { relativeTime: '1h' });
const documentId = await savedViews.documentId();
await savedViews.openList();
await savedViews.search(target.displayName);
await savedViews.open(target);
await savedViews.expectClean(target.displayName);
await expect(savedViews.rowLink(target.id)).toHaveAttribute(
'aria-current',
'page',
);
expect(savedViews.param('relativeTime')).toBe('1h');
expect(await savedViews.documentId()).toBe(documentId);
expect(savedViews.newTabCount()).toBe(0);
await savedViews.search(other.displayName);
await expect(savedViews.rowLink(other.id)).not.toHaveAttribute(
'aria-current',
'page',
);
});
for (const gesture of ['modifier', 'middle'] as const) {
test(`TC-04 a ${gesture} click opens the view in a new tab and leaves this one alone`, async ({
savedViews,
}) => {
const { target } = await seedTwoViews(savedViews);
await savedViews.openExplorer('logs', { relativeTime: '1h' });
await savedViews.openList();
await savedViews.search(target.displayName);
const urlBefore = savedViews.url().href;
const { savedViews: opened } = await savedViews.openInNewTab(
target.id,
gesture,
);
await opened.queryRan(target.filter);
await opened.expectClean(target.displayName);
expect(opened.activeViewId()).toBe(target.id);
expect(opened.param('relativeTime')).toBe('1h');
await expect(opened.queryBar()).toHaveText(target.filter);
expect(savedViews.url().href).toBe(urlBefore);
await expect(savedViews.name()).toHaveText('My view');
});
}
});
test.describe('Saved views: copied links', () => {
test('TC-05 a copied link reopens the view in the same relative time range', async ({
savedViews,
}) => {
const { target } = await seedTwoViews(savedViews);
await savedViews.openExplorer('logs', { relativeTime: '1h' });
await savedViews.openList();
await savedViews.search(target.displayName);
const copied = new URL(await savedViews.copyLink(target.id));
await expect(savedViews.toast('Link copied')).toBeVisible();
expect(copied.origin).toBe(savedViews.url().origin);
expect(copied.pathname).toBe(LOGS_PATH);
expect(copied.searchParams.get('relativeTime')).toBe('1h');
expect(copied.searchParams.get('viewKey')).toBe(JSON.stringify(target.id));
const { savedViews: reopened } = await savedViews.openLink(copied, target);
await reopened.expectClean(target.displayName);
expect(reopened.activeViewId()).toBe(target.id);
expect(reopened.param('relativeTime')).toBe('1h');
await expect(reopened.queryBar()).toHaveText(target.filter);
});
test('TC-06 a copied link reopens the view in the same custom time range', async ({
savedViews,
}) => {
const { target } = await seedTwoViews(savedViews);
const range = customRange();
await savedViews.openExplorer('logs', range);
await savedViews.openList();
await savedViews.search(target.displayName);
const copied = new URL(await savedViews.copyLink(target.id));
expect(copied.searchParams.get('startTime')).toBe(range.startTime);
expect(copied.searchParams.get('endTime')).toBe(range.endTime);
expect(copied.searchParams.has('relativeTime')).toBe(false);
const { savedViews: reopened } = await savedViews.openLink(copied, target);
await reopened.expectClean(target.displayName);
expect(reopened.param('startTime')).toBe(range.startTime);
expect(reopened.param('endTime')).toBe(range.endTime);
});
});

View File

@@ -32,12 +32,12 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _create_alert_rule(rule_data: dict) -> str:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/rules"),
signoz.self.host_configs["8080"].get("/api/v2/rules"),
json=rule_data,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
assert response.status_code == HTTPStatus.CREATED, f"Failed to create rule, api returned {response.status_code} with response: {response.text}"
rule_id = response.json()["data"]["id"]
rule_ids.append(rule_id)
return rule_id
@@ -45,11 +45,11 @@ def create_alert_rule(signoz: types.SigNoz, get_token: Callable[[str, str], str]
def _delete_alert_rule(rule_id: str):
logger.info("Deleting rule: %s", {"rule_id": rule_id})
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
if response.status_code != HTTPStatus.OK:
if response.status_code != HTTPStatus.NO_CONTENT:
raise Exception( # pylint: disable=broad-exception-raised
f"Failed to delete rule, api returned {response.status_code} with response: {response.text}"
)
@@ -156,19 +156,22 @@ def create_downtime_schedule(signoz: types.SigNoz, get_token: Callable[[str, str
def delete_all_rules(signoz: types.SigNoz, token: str) -> None:
# the v3 list is paginated; 200 is its maximum page and more rules than
# that would mean a seeding bug, not a second page
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/rules"),
signoz.self.host_configs["8080"].get("/api/v3/rules"),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
for rule in response.json()["data"]:
for rule in response.json()["data"]["rules"]:
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/rules/{rule['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/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}"
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"failed to delete rule {rule['id']}: {delete_response.text}"
@pytest.fixture(name="seed_alert_rules", scope="function")

View File

@@ -1,69 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
build_builder_query,
build_function,
get_all_series,
index_series_by_label,
make_query_request,
)
MINUTE = timedelta(minutes=1)
def test_running_diff_full_hit_matches_no_cache(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""
Setup:
A gauge that grows by 10 every minute over four minutes.
Tests:
runningDiff over the last three of those minutes, requested twice. The
metrics statement reads one step before the window so the first
interval has a difference. The second request, a full cache hit, must
return the same three differences as the request with noCache.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# the cache outlives the run, so a fixed name would serve the previous
# run's points back to this one
metric_name = f"cache_running_diff_{uuid4().hex[:8]}"
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "svc-a"},
timestamp=now - (14 - i) * MINUTE,
value=100.0 + 10 * i,
type_="Gauge",
is_monotonic=False,
)
for i in range(4)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query("A", metric_name, "avg", "avg", group_by=["service"], functions=[build_function("runningDiff")])
start_ms = int((now - 13 * MINUTE).timestamp() * 1000)
end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
warm = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
assert warm.status_code == HTTPStatus.OK, warm.text
cached = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=False)
assert cached.status_code == HTTPStatus.OK, cached.text
fresh = make_query_request(signoz, token, start_ms, end_ms, [query], no_cache=True)
assert fresh.status_code == HTTPStatus.OK, fresh.text
fresh_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(fresh.json(), "A"), "service")["svc-a"]["values"]}
assert sorted(fresh_points.values()) == [10, 10, 10], fresh_points
cached_points = {point["timestamp"]: point["value"] for point in index_series_by_label(get_all_series(cached.json(), "A"), "service")["svc-a"]["values"]}
assert cached_points == fresh_points

View File

@@ -573,24 +573,3 @@ def test_error_contract(
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[[str, list[dict]], None],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seed_alert_rules(SEED_CHANNEL_NAME, 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,446 @@
import time
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/v2/rules"
VALID_RULE = {
"alert": "validation probe",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "validation_probe_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": False, "renotify": {"enabled": False}},
"schemaVersion": "v2alpha1",
}
def test_create_rejects_missing_alert_type(
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)
rule = {key: value for key, value in VALID_RULE.items() if key != "alertType"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "alertType: field is required", "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_unknown_panel_type(
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)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "compositeQuery": {**VALID_RULE["condition"]["compositeQuery"], "panelType": "pie"}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'condition.compositeQuery.panelType: unsupported value "pie"; must be one of value, table, graph', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_malformed_json(
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.post(
signoz.self.host_configs["8080"].get(BASE_URL),
data='{"alert": ',
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "failed to parse json: unexpected end of JSON input",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_unnamed_threshold(
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)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [{"name": "", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "threshold name cannot be empty", "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_duplicate_threshold_names(
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)
threshold = {"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [threshold, {**threshold, "target": 95}]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'condition.thresholds.spec: duplicate threshold name "critical"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_empty_schema_version(
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.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "schemaVersion": ""},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'schemaVersion: unsupported value ""; must be "v2alpha1"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_defaults_clickhouse_rule_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {key: value for key, value in VALID_RULE.items() if key != "ruleType"}
rule["condition"] = {**VALID_RULE["condition"], "compositeQuery": {"queryType": "clickhouse_sql", "panelType": "graph", "queries": [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT now() AS ts, 1 AS value"}}]}}
rule_id = create_alert_rule(rule)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["ruleType"] == "threshold_rule"
def test_get_echoes_alert_on_absent_false(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule({**VALID_RULE, "condition": {**VALID_RULE["condition"], "alertOnAbsent": False, "absentFor": 0}})
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["condition"]["alertOnAbsent"] is False
assert response.json()["data"]["condition"]["absentFor"] == 0
def test_timeline_rejects_unknown_state(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule(VALID_RULE)
end_ms = int(time.time() * 1000)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}/history/timeline"),
params={"start": end_ms - 3_600_000, "end": end_ms, "state": "bogus"},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "invalid query parameters",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_unknown_field(
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.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "preferredChannels": ["slack"]},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": 'unknown field "preferredChannels" in alert rule',
"errors": [],
"suggestions": ["valid fields are `alert`, `alertType`, `description`, `ruleType`, `condition`, `labels`, `annotations`, `disabled`, `source`, `version`, `evaluation`, `schemaVersion`, `notificationSettings`"],
},
}
def test_create_rejects_numeric_eval_window(
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.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "evaluation": {"kind": "rolling", "spec": {"evalWindow": 300, "frequency": "1m"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": 'evaluation.spec.evalWindow: must be a duration string such as "5m"', "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_recovery_target_past_target(
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)
thresholds = {"kind": "basic", "spec": [{**VALID_RULE["condition"]["thresholds"]["spec"][0], "recoveryTarget": 95}]}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": thresholds}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "condition.thresholds.spec: recoveryTarget must be below target for op above", "suggestions": []}],
"suggestions": [],
},
}
def test_list_defaults_rule_type(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_alert_rule: Callable[[dict], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule_id = create_alert_rule({key: value for key, value in VALID_RULE.items() if key != "ruleType"})
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/rules"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
listed = next(rule for rule in response.json()["data"]["rules"] if rule["id"] == rule_id)
assert listed["ruleType"] == "threshold_rule"
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=VALID_RULE,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "id is not a valid uuid",
"errors": [],
"suggestions": [],
},
}
def test_create_rejects_channel_by_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
channel_id = create_webhook_notification_channel(f"Rule Validation {time.time_ns()}", "http://localhost:9/alert", {}, False)
channel = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/notification_channels/{channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]
thresholds = {"kind": "basic", "spec": [{**VALID_RULE["condition"]["thresholds"]["spec"][0], "channels": [channel["name"]]}]}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": thresholds}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": f'channels: rules reference channels by display name, not name: [{channel["name"]} (display name "{channel["displayName"]}")]',
"errors": [],
"suggestions": [],
},
}

View File

@@ -0,0 +1,119 @@
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/rules"
VALID_RULE = {
"alert": "threshold probe",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": []}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "threshold_probe_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m", "frequency": "1m"}},
"notificationSettings": {"usePolicy": False, "renotify": {"enabled": False}},
"schemaVersion": "v2alpha1",
}
@pytest.mark.parametrize(
("threshold", "expected_message"),
[
(
{"name": "critical", "target": None, "matchType": "at_least_once", "op": "above", "channels": []},
"target value cannot be nil",
),
(
{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "", "channels": []},
'condition.op: unsupported value ""; must be one of above, below, equal, not_equal, above_or_equal, below_or_equal, outside_bounds',
),
(
{"name": "critical", "target": 90, "matchType": "0", "op": "above", "channels": []},
'condition.matchType: unsupported value "0"; must be one of at_least_once, all_the_times, on_average, in_total, last',
),
],
ids=["null_target", "empty_op", "unknown_match_type"],
)
def test_create_rejects_invalid_threshold(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
threshold: dict,
expected_message: str,
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
rule = {**VALID_RULE, "condition": {**VALID_RULE["condition"], "thresholds": {"kind": "basic", "spec": [threshold]}}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": expected_message, "suggestions": []}],
"suggestions": [],
},
}
def test_create_rejects_omitted_selected_query_name(
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)
rule = {**VALID_RULE, "condition": {key: value for key, value in VALID_RULE["condition"].items() if key != "selectedQueryName"}}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=rule,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json() == {
"status": "error",
"error": {
"type": "invalid-input",
"code": "invalid_input",
"message": "alert rule is not valid",
"errors": [{"message": "condition.selectedQueryName: field is required", "suggestions": []}],
"suggestions": [],
},
}