Compare commits

...

7 Commits

Author SHA1 Message Date
srikanthccv
d4bd8be09c test(querier): reproduce the lost first runningDiff on a full cache hit
The metrics statement reads one step before the window for runningDiff,
and the cache does not keep that step.

Assisted-by: Claude Fable 5.1
2026-11-08 22:30:30 +05:30
Srikanth Chekuri
43cde62108 test(alertmanager): fix flaky TestAggrGroup timing and resolve checks (#12131)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
## Description

- TestAggrGroup failed four times this week in CI with "received batch
too early after 299.5ms" and "Expected aggregation group to be empty
after resolving alerts".
- The test timed batches with its own clock. The group resets its timer
before it calls notify, so two ticks can be under one group interval on
the test's clock by a millisecond. The test now reads the tick time the
group puts in the context, which is the clock the group itself uses.
- The empty check ran as soon as the batch arrived, but the group
deletes resolved alerts after notify returns. The test now waits for the
delete.
- Checks inside the notify callback use assert, not require, because the
callback runs on the group's goroutine.

## Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5121
Closes https://github.com/SigNoz/engineering-pod/issues/5636

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-07 19:15:26 +00:00
Srikanth Chekuri
94367f4575 fix(querybuilder): warn on unknown keys outside the filter (#13421)
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

`ResolveColumn` dropped the not-found warning from `Resolve`, so a group
by, select field, order by, or aggregation on an unknown key returned
data with no warning. The le bucket key the histogram rewrite adds does
not warn, and meter queries still report no warnings.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#600

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-07 10:51:41 +00:00
Srikanth Chekuri
108b2d8694 fix(querybuilder): parse aggregation predicates and field names like filters (#13418)
#### Description

- Gracefully handle aggregation keys names
  - otherwise `sum(http.response.body-size)` became a subtraction
- `count_distinct(k8s.pod.labels.app.kubernetes.io/name)` a division,
and both returned null or 0 with no warning to agent.
- Parsing failure, a wrong argument count, or an argument that is not a
field name now returns 400, earlier 500.
- Each argument of an aggregation call now comes from the source text
before the SQL parse. Predicates go to the filter grammar. A value
argument must be one filter-grammar key, so keys with `-`, `/` and
`:type` resolve.
- rewriter parsed the whole expression with the chsql parser before the
filter grammar saw the predicate. Filter-only syntax in an `*If`
predicate (`EXISTS`, `NOT EXISTS`, `CONTAINS`, `NOT CONTAINS`) failed in
that parser and returned 500.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#599
2026-11-07 09:41:13 +00:00
Srikanth Chekuri
fa6fda338f fix(querybuilder): read an unknown body path in a column as a string (#13429)
#### Description

- A `body.` key that metadata does not report failed with 404 "field not
found" in a select, group by, order by, or aggregation. The logs
fallback kept it with no data type, and the JSON body read needs one. A
filter on the same key worked, because the operand types it.
- The fallback now types it as `bodyPath` does for a bare key: a string
path when no operand types it. Filter SQL does not change.
- `TestResolveColumn_UnknownKey` resolves an unknown key in a column for
every storage and field context and allows only invalid input as an
error. On main, only the logs JSON body `body` context failed.
- The body path parity integration test now covers the `body.` spelling,
and group by, `count_distinct`, and select.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#610

#### Additional Information

- The not-found warning for group by and select reaches the response
with #13421. #13421 also changes `ResolveColumn` to return warnings; the
second PR to merge updates the call in `resolve_test.go`.
2026-11-07 09:39:16 +00:00
Abhi kumar
d1f4503b40 test(query-builder): add e2e suite for the query builder (#13313)
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

- Adds a host-agnostic `qb` driver
(`tests/e2e/drivers/query-builder.ts`) and ~110 query builder tests
across the logs, traces and metrics explorers, the V2 panel editor and
alerts. Assertions read the outgoing `query_range` body and the URL
query, never exact counts.
- Covers URL handling (malformed, partial, legacy shapes,
back/forward/reload), the filter editor (suggestions, quoting, IN lists,
nesting, validation, keyboard, recents, quick filters), metric-type
defaults, aggregations, add-ons per panel kind, multi-query and
formulas, trace operator, signal and query-type switching, cross-host
round trips (explorer → dashboard, alert save → reopen) and
run/cancel/error states.
- Each regression-driven case names the fix it guards (#13257, #12705,
#12496, #10048, #9947, #9732, #11208, #9637, #11035, #10100, #12755,
#9066, #8250).
- Seeds a gauge and a histogram per worker (golden metrics are all
sums); exports the seeder's retrying POST for that.
- Shared helper fixes found under load: `pickChannelByName` re-clicks
while the select is still closed; `holdQueryRange` tolerates a request
cancelled while held.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#662

#### Additional Information

- Stacked: #13315 (legacy having and limit fixes) → #13323 (added query
keeps its signal) → this PR. Merge in that order.
- Regression tests for both fixes (legacy having `COUNT() > 5`, negative
limit, added query's signal) fail against the unfixed app.
- Stable across 2 consecutive CI-mode runs on chromium (198 passed, 0
flaky). Meter source switching is not covered yet: the stack has no
meter metrics.
2026-11-06 20:25:12 +00:00
ashu8912
cbab3ca8d7 feat(alerts): add DSL search and saved views to alert rules list (#13322)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Alert rules list now uses the server-side `/api/v3/rules` endpoint.
Filtering, sorting and pagination happen on the backend instead of the
client.
- The search bar takes the filter DSL (`name`, `severity`, `alert_type`,
`rule_type`, `created_by`, `labels.<key>`, …) with autocomplete. Invalid
queries show the backend's error inline.
- New saved views rail: built-in views (My alert rules, All, Firing,
Disabled) plus org-shared custom views backed by `/api/v2/rule_views`.
- Search bar, filter zone and views rail come from the shared
`components/ListFilter` and `components/ListViews`.

#### Issues closed by this PR

Closes: https://github.com/SigNoz/pulse-pod/issues/594

#### Screenshots / Screen Recordings

[Visual diff](https://claude.ai/artifact/944j4x95qf1Ybh1pEw8uNt) against
#13372, dark and light: 138 pairs, 86 changed, 22 new, 2 removed, 28
identical. The `alert-rules` tag holds this PR's changes; Dashboards
List pairs are within the measured noise floor.

#### Additional Information

- Depends on #13372; review after it merges.
2026-11-06 14:31:21 +00:00
94 changed files with 6133 additions and 857 deletions

View File

@@ -0,0 +1,69 @@
import { RULES_DSL_GRAMMAR } from 'container/ListAlertRules/list.config';
import { getSuggestions } from './dslSuggestions';
import type { SuggestionSource } from './types';
const source: SuggestionSource = {
reservedKeys: [
'alert_type',
'created_at',
'created_by',
'labels.<key>',
'name',
'rule_type',
'severity',
'updated_at',
'updated_by',
],
dynamicKeys: ['labels.team', 'labels.env'],
valuesByKey: { 'labels.team': ['infra'], 'labels.severity': ['critical'] },
creatorEmails: ['alice@x.io'],
currentUserEmail: 'me@x.io',
};
const suggest = (q: string): ReturnType<typeof getSuggestions>['items'] =>
getSuggestions(q, q.length, source, RULES_DSL_GRAMMAR).items;
const labels = (q: string): string[] => suggest(q).map((i) => i.label);
describe('getSuggestions — rules grammar', () => {
it('orders reserved keys, hides labels.<key> and appends label keys', () => {
expect(labels('')).toStrictEqual([
'name',
'severity',
'alert_type',
'rule_type',
'created_by',
'updated_by',
'created_at',
'updated_at',
'labels.team',
'labels.env',
]);
expect(suggest('labels.t').map((i) => i.detail)).toStrictEqual(['label']);
});
it('restricts enum keys to = != IN NOT IN and suggests their values', () => {
expect(labels('rule_type ')).toStrictEqual([
'rule_type =',
'rule_type !=',
'rule_type IN',
'rule_type NOT IN',
]);
expect(labels('rule_type = ')).toStrictEqual([
'threshold_rule',
'promql_rule',
'anomaly_rule',
]);
expect(labels("alert_type = 'LOGS")).toStrictEqual(['LOGS_BASED_ALERT']);
});
it('gives severity and labels.* the label operators and values', () => {
expect(labels('severity ')).toContain('severity EXISTS');
expect(labels('severity = ')).toStrictEqual(['critical']);
expect(labels('labels.team = ')).toStrictEqual(['infra']);
});
it('suggests user emails for updated_by', () => {
expect(labels('updated_by = ')).toStrictEqual(['me@x.io (me)', 'alice@x.io']);
});
});

View File

@@ -8,6 +8,8 @@ interface Props {
count: number;
isModified: boolean;
action?: ReactNode;
// Ids: `${testIdPrefix}-list-heading`.
testIdPrefix?: string;
}
function ListCommandHeader({
@@ -15,11 +17,17 @@ function ListCommandHeader({
count,
isModified,
action,
testIdPrefix,
}: Props): JSX.Element {
return (
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>{label}</Typography.Title>
<Typography.Title
className={styles.title}
testId={testIdPrefix && `${testIdPrefix}-list-heading`}
>
{label}
</Typography.Title>
{isModified && <span className={styles.dirtyDot} title="Unsaved changes" />}
<span className={styles.countPill}>{count}</span>
</div>

View File

@@ -45,6 +45,7 @@ export enum LOCALSTORAGE {
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
ALERT_RULES_LIST_VIEWS = 'ALERT_RULES_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
}

View File

@@ -172,6 +172,9 @@
}
.infoLinkContainer {
display: flex;
align-items: center;
svg {
color: var(--primary);
}

View File

@@ -1,5 +1,4 @@
import { ArrowRight, CirclePlay } from '@signozhq/icons';
import { Flex } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
@@ -21,7 +20,8 @@ function InfoLinkText({
onClick,
}: InfoLinkTextProps): JSX.Element {
return (
<Flex
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
onClick={(): void => {
onClick();
openInNewTab(link);
@@ -33,7 +33,7 @@ function InfoLinkText({
{rightIconVisible && (
<ArrowRight size={16} style={{ transform: 'rotate(315deg)' }} />
)}
</Flex>
</div>
);
}

View File

@@ -1,46 +1,30 @@
.container {
.layout {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: stretch;
flex: 1;
min-height: 400px;
width: 100%;
}
.header {
position: relative;
z-index: 10;
display: flex;
justify-content: flex-end;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
}
.refreshRow {
display: flex;
align-items: center;
gap: 8px;
}
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
padding: 0 var(--spacing-8);
--combobox-trigger-height: 2rem;
}
.searchInput {
min-width: 250px;
}
.filterSelect {
min-width: 300px;
.main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--l1-background);
}
.headerZone {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 14px;
padding: 20px 24px;
background: var(--l2-background);
border-bottom: 1px solid var(--l2-border);
}
.tableContainer {
@@ -77,10 +61,6 @@
--badge-cursor: pointer;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsColumn {
display: flex;
justify-content: flex-end;

View File

@@ -1,10 +1,14 @@
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import userEvent from '@testing-library/user-event';
import ROUTES from 'constants/routes';
import { alertRulesFixture } from 'mocks-server/__mockdata__/alert_rules';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen } from 'tests/test-utils';
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
import { screen, waitFor } from 'tests/test-utils';
import { renderListAlertRules } from './_helpers';
@@ -17,8 +21,11 @@ describe('ListAlertRules — empty states', () => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [], status: 'success' })),
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(listRulesV3Response([], req.url.searchParams)),
),
),
);
@@ -42,14 +49,14 @@ describe('ListAlertRules — empty states', () => {
let callCount = 0;
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) => {
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
callCount += 1;
if (callCount === 1) {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.status(200),
ctx.json({ data: alertRulesFixture, status: 'success' }),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
@@ -64,18 +71,29 @@ describe('ListAlertRules — empty states', () => {
expect(rule).toBeInTheDocument();
});
it('renders NoResultsEmptyState when search yields no match; Clear Search resets', async () => {
it('renders NoResultsEmptyState when the query matches nothing; Clear Search resets it', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(
listRulesV3Response(
req.url.searchParams.has('query') ? [] : alertRulesFixture,
req.url.searchParams,
),
),
),
),
);
await screen.findByText('High CPU Alert');
const searchInput = screen.getByTestId('list-alerts-search-input');
await user.clear(searchInput);
await user.type(searchInput, 'totally-not-found');
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent("name = 'missing'")}`,
});
await screen.findByTestId('no-results-empty-state');
expect(screen.queryByText('No Alert rules yet.')).not.toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
@@ -85,7 +103,28 @@ describe('ListAlertRules — empty states', () => {
await user.click(screen.getByTestId('no-results-clear-button'));
const rule = await screen.findByText('High CPU Alert');
expect(rule).toBeInTheDocument();
await expect(
screen.findByText('High CPU Alert'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
});
});
it('renders NoResultsEmptyState when the states filter matches nothing; Clear Search resets it', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?states=nodata' });
await screen.findByTestId('no-results-empty-state');
await user.click(screen.getByTestId('no-results-clear-button'));
await expect(
screen.findByText('High CPU Alert'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(getCurrentNuqsSearchParams().has('states')).toBe(false);
});
});
});

View File

@@ -101,17 +101,18 @@ describe('ListAlertRules — list rendering', () => {
);
});
it('renders header controls (search, columns, new alert)', async () => {
it('renders header controls (search, states, columns, new alert)', async () => {
renderListAlertRules();
await waitFor(() =>
expect(screen.getByTestId('alert-row-rule-1-name')).toBeInTheDocument(),
);
expect(screen.getByTestId('list-alerts-search-input')).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-list-search')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by Alert Name, Severity and Labels'),
screen.getByTestId('alert-rules-list-search-submit'),
).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-filter-states')).toBeInTheDocument();
expect(screen.getByTestId('alert-columns-button')).toBeInTheDocument();
expect(
screen.getByTestId('list-alerts-new-alert-button'),

View File

@@ -1,5 +1,8 @@
import userEvent from '@testing-library/user-event';
import { alertRulesPaginationFixture } from 'mocks-server/__mockdata__/alert_rules';
import {
alertRulesPaginationFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, waitFor } from 'tests/test-utils';
@@ -8,31 +11,38 @@ import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
describe('ListAlertRules — pagination', () => {
let requests: URLSearchParams[];
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
requests = [];
server.use(
rest.get('http://localhost/api/v2/rules', (_, res, ctx) =>
res(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({ data: alertRulesPaginationFixture, status: 'success' }),
),
),
ctx.json(
listRulesV3Response(alertRulesPaginationFixture, req.url.searchParams),
),
);
}),
);
});
it('shows first 10 rows on page 1 (default limit)', async () => {
it('requests the first page with the default limit and renders it', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
expect(requests[0].get('limit')).toBe('10');
expect(requests[0].get('offset')).toBe('0');
for (let i = 0; i < 10; i += 1) {
expect(screen.getByText(`Pag Rule ${i}`)).toBeInTheDocument();
}
expect(screen.queryByText('Pag Rule 10')).not.toBeInTheDocument();
expect(screen.queryByText('Pag Rule 14')).not.toBeInTheDocument();
});
it('shows total count when showTotalCount is enabled', async () => {
it('shows the total count from the API response', async () => {
renderListAlertRules();
await screen.findByText('Pag Rule 0');
@@ -42,14 +52,13 @@ describe('ListAlertRules — pagination', () => {
expect(totalCount.textContent).toContain('of 15');
});
it('navigates to page 2 and shows remaining rows', async () => {
it('navigates to page 2 by requesting the next offset', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('Pag Rule 0');
const nextBtn = screen.getByLabelText('Go to next page');
await user.click(nextBtn);
await user.click(screen.getByLabelText('Go to next page'));
await waitFor(() => {
expect(screen.getByText('Pag Rule 10')).toBeInTheDocument();
@@ -57,8 +66,20 @@ describe('ListAlertRules — pagination', () => {
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
const lastRequest = requests[requests.length - 1];
expect(lastRequest.get('limit')).toBe('10');
expect(lastRequest.get('offset')).toBe('10');
await waitFor(() => {
expect(getCurrentNuqsQueryString()).toContain('page=2');
});
});
it('requests the page from the URL on load', async () => {
renderListAlertRules({ initialRoute: '/?page=2' });
await screen.findByText('Pag Rule 10');
expect(requests[0].get('offset')).toBe('10');
expect(screen.queryByText('Pag Rule 0')).not.toBeInTheDocument();
});
});

View File

@@ -9,7 +9,7 @@ describe('ListAlertRules — row click navigation', () => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
});
it('clicking a row calls safeNavigate to alerts/overview with composite query + ruleId', async () => {
it('clicking a row calls safeNavigate to alerts/overview with ruleId', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
@@ -26,8 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).toContain('compositeQuery=');
expect(url).not.toContain('compositeQuery=');
});
it('ctrl+click on a row navigates with newTab option', async () => {

View File

@@ -1,99 +1,217 @@
import userEvent from '@testing-library/user-event';
import { screen, waitFor } from 'tests/test-utils';
import { getCurrentNuqsQueryString } from 'tests/nuqs-helpers';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { screen, userEvent, waitFor, within } from 'tests/test-utils';
import {
getCurrentNuqsQueryString,
getCurrentNuqsSearchParams,
} from 'tests/nuqs-helpers';
import { renderListAlertRules } from './_helpers';
import {
getSearchDraft,
renderListAlertRules,
runSearchQuery,
setSearchDraft,
} from './_helpers';
function getSearchInput(): HTMLInputElement {
return screen.getByTestId('list-alerts-search-input') as HTMLInputElement;
const QUERY = "name CONTAINS 'CPU'";
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
return requests[requests.length - 1];
}
describe('ListAlertRules — search', () => {
let requests: URLSearchParams[];
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
requests = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
});
it('filters rows by alert name with debounce', async () => {
it('sends the draft query only when Run is clicked', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
setSearchDraft(QUERY);
await expect(
screen.findByTestId('alert-rules-list-search-dirty'),
).resolves.toBeInTheDocument();
expect(requests.some((r) => r.has('query'))).toBe(false);
expect(getCurrentNuqsSearchParams().has('query')).toBe(false);
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
expect(lastRequest(requests).get('query')).toBe(QUERY);
});
await waitFor(() => {
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(
screen.queryByTestId('alert-rules-list-search-dirty'),
).not.toBeInTheDocument();
});
it('filters rows by label values (severity)', async () => {
it('runs the draft on Mod-Enter', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'warning');
setSearchDraft(QUERY);
const content = screen
.getByTestId('alert-rules-list-search')
.querySelector<HTMLElement>('.cm-content');
if (!content) {
throw new Error('Editor content not found');
}
content.focus();
await user.keyboard('{Control>}{Enter}{/Control}');
await waitFor(() => {
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
});
it('restores all rows when search is cleared', async () => {
it('trims the submitted query', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'CPU');
await runSearchQuery(user, ` ${QUERY} `);
await waitFor(() => {
expect(screen.queryByText('Memory Pending Alert')).not.toBeInTheDocument();
});
await user.clear(getSearchInput());
await waitFor(() => {
expect(screen.getByText('High CPU Alert')).toBeInTheDocument();
expect(screen.getByText('Memory Pending Alert')).toBeInTheDocument();
expect(screen.getByText('Healthy Alert')).toBeInTheDocument();
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(lastRequest(requests).get('query')).toBe(QUERY);
});
it('shows no-results state when no match', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
it('loads the query from the URL into the editor and the request', async () => {
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent(QUERY)}`,
});
await screen.findByText('High CPU Alert');
await user.clear(getSearchInput());
await user.type(getSearchInput(), 'zzzzzz-no-match');
await waitFor(() => {
expect(screen.getByTestId('no-results-empty-state')).toBeInTheDocument();
expect(screen.getByTestId('no-results-title')).toHaveTextContent(
'No matching alert rules',
);
});
expect(getSearchDraft()).toBe(QUERY);
expect(requests[0].get('query')).toBe(QUERY);
});
it('resets page to 1 when search debounce fires', async () => {
it('resets page to 1 when a query is run', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({ initialRoute: '/?page=2' });
// Page 2 of the 4-rule fixture has no rows; we only need the search input
// to be mounted, which happens before data is fetched.
const input = await screen.findByTestId('list-alerts-search-input');
await user.clear(input);
await user.type(input, 'CPU');
await screen.findByTestId('alert-rules-list-search');
await runSearchQuery(user, QUERY);
await waitFor(() => {
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
expect(getCurrentNuqsSearchParams().get('query')).toBe(QUERY);
});
expect(getCurrentNuqsQueryString()).not.toContain('page=2');
await waitFor(() => {
expect(lastRequest(requests).get('offset')).toBe('0');
});
});
it('sends each selected state as a repeated states= param', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByTestId('alert-rules-filter-states'));
await user.click(await screen.findByRole('option', { name: 'Firing' }));
await user.click(await screen.findByRole('option', { name: 'Disabled' }));
await waitFor(() => {
expect(lastRequest(requests).getAll('states')).toStrictEqual([
'firing',
'disabled',
]);
});
expect(lastRequest(requests).has('states[]')).toBe(false);
await waitFor(() => {
expect(getCurrentNuqsSearchParams().get('states')).toBe('firing,disabled');
});
});
it('Clear empties the query and the states', async () => {
const user = userEvent.setup({ delay: null });
renderListAlertRules({
initialRoute: `/?query=${encodeURIComponent(QUERY)}&states=firing`,
});
await screen.findByText('High CPU Alert');
expect(lastRequest(requests).get('query')).toBe(QUERY);
await user.click(screen.getByTestId('alert-rules-filter-clear'));
await waitFor(() => {
const params = getCurrentNuqsSearchParams();
expect(params.has('query')).toBe(false);
expect(params.has('states')).toBe(false);
});
await waitFor(() => {
const last = lastRequest(requests);
expect(last.has('query')).toBe(false);
expect(last.has('states')).toBe(false);
});
expect(getSearchDraft()).toBe('');
expect(
screen.queryByTestId('alert-rules-filter-clear'),
).not.toBeInTheDocument();
});
it.each(['rule_list_filter_invalid', 'rule_list_invalid'])(
'shows the backend message and no-results state for %s',
async (code) => {
const user = userEvent.setup({ delay: null });
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
if (req.url.searchParams.has('query')) {
return res(
ctx.status(400),
ctx.json({ error: { code, message: 'unknown field "nme"' } }),
);
}
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
renderListAlertRules();
await screen.findByText('High CPU Alert');
await runSearchQuery(user, "nme = 'x'");
await expect(
screen.findByTestId('alert-rules-query-error'),
).resolves.toHaveTextContent('unknown field "nme"');
const empty = screen.getByTestId('no-results-empty-state');
expect(within(empty).getByTestId('no-results-subtitle')).toHaveTextContent(
'The filter query is invalid. Fix it or clear the search.',
);
expect(screen.queryByTestId('error-empty-state')).not.toBeInTheDocument();
expect(screen.queryByText('High CPU Alert')).not.toBeInTheDocument();
},
);
});

View File

@@ -1,14 +1,19 @@
import { logEventMock } from '__tests__/logEventMock';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import {
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import type { AlertRule } from '../types';
import type { AlertRule, RuleListFilter } from '../types';
import {
ALERT_ACTIONS,
alertActionLogEvent,
filterRulesByFilters,
getAlertSortValue,
sortRules,
DEFAULT_RULE_LIST_FILTER,
isDefaultRuleListFilter,
isSameRuleListFilter,
toListSort,
toOrderBy,
} from '../utils';
const baseRule = {
@@ -27,159 +32,116 @@ const makeRule = (overrides: Partial<AlertRule>): AlertRule => ({
...overrides,
});
describe('getAlertSortValue', () => {
it('returns state for "state"', () => {
expect(
getAlertSortValue(
makeRule({ state: RuletypesAlertStateDTO.firing }),
'state',
),
).toBe('firing');
describe('toListSort', () => {
it('returns the default sort when orderBy is null', () => {
expect(toListSort(null)).toStrictEqual({
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
});
});
it('returns alert name for "name"', () => {
expect(getAlertSortValue(makeRule({ alert: 'My Rule' }), 'name')).toBe(
'My Rule',
);
it('returns the default sort for an unknown column', () => {
expect(toListSort({ columnName: 'labels', order: 'asc' })).toStrictEqual({
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
});
});
it('returns severity label for "severity"', () => {
expect(
getAlertSortValue(
makeRule({ labels: { severity: 'critical' } }),
'severity',
),
).toBe('critical');
it.each([
['state', RuletypesListSortDTO.state],
['name', RuletypesListSortDTO.name],
['severity', RuletypesListSortDTO.severity],
['createdAt', RuletypesListSortDTO.created_at],
['updatedAt', RuletypesListSortDTO.updated_at],
])('maps column "%s" to sort "%s"', (columnName, sort) => {
expect(toListSort({ columnName, order: 'asc' })).toStrictEqual({
sort,
order: RuletypesListOrderDTO.asc,
});
});
it('returns createdAt as ms', () => {
const rule = makeRule({ createdAt: '2023-10-15T10:00:00Z' });
const result = getAlertSortValue(rule, 'createdAt');
expect(result).toBe(new Date('2023-10-15T10:00:00Z').getTime());
});
it('returns updatedAt as ms', () => {
const rule = makeRule({ updatedAt: '2023-10-19T10:00:00Z' });
const result = getAlertSortValue(rule, 'updatedAt');
expect(result).toBe(new Date('2023-10-19T10:00:00Z').getTime());
});
it('returns 0 when createdAt missing', () => {
expect(
getAlertSortValue(makeRule({ createdAt: undefined }), 'createdAt'),
).toBe(0);
});
it('returns empty for unknown column', () => {
expect(getAlertSortValue(baseRule, 'xxx')).toBe('');
});
it('returns empty for missing fields', () => {
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'state',
),
).toBe('');
expect(
getAlertSortValue(
makeRule({ state: undefined, labels: undefined }),
'severity',
),
).toBe('');
it('maps desc order', () => {
expect(toListSort({ columnName: 'name', order: 'desc' })).toStrictEqual({
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.desc,
});
});
});
describe('sortRules', () => {
const r1 = makeRule({ id: '1', alert: 'A' });
const r2 = makeRule({ id: '2', alert: 'B' });
const r3 = makeRule({ id: '3', alert: 'C' });
it('sorts ascending by name', () => {
const order: SortState = { columnName: 'name', order: 'asc' };
const result = sortRules([r3, r1, r2], order);
expect(result.map((r) => r.alert)).toStrictEqual(['A', 'B', 'C']);
describe('toOrderBy', () => {
it('returns null for the default sort', () => {
expect(
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.desc),
).toBeNull();
});
it('sorts descending by name', () => {
const order: SortState = { columnName: 'name', order: 'desc' };
const result = sortRules([r1, r2, r3], order);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'B', 'A']);
it('returns the column for a non-default sort', () => {
expect(
toOrderBy(RuletypesListSortDTO.created_at, RuletypesListOrderDTO.asc),
).toStrictEqual<SortState>({ columnName: 'createdAt', order: 'asc' });
expect(
toOrderBy(RuletypesListSortDTO.updated_at, RuletypesListOrderDTO.asc),
).toStrictEqual<SortState>({ columnName: 'updatedAt', order: 'asc' });
});
it('returns unsorted when orderBy is null', () => {
const result = sortRules([r3, r1, r2], null);
expect(result.map((r) => r.alert)).toStrictEqual(['C', 'A', 'B']);
it('round-trips with toListSort', () => {
const orderBy: SortState = { columnName: 'severity', order: 'desc' };
const { sort, order } = toListSort(orderBy);
expect(toOrderBy(sort, order)).toStrictEqual(orderBy);
});
});
describe('filterRulesByFilters', () => {
const r1 = makeRule({
id: '1',
alert: 'R1',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'critical' },
});
const r2 = makeRule({
id: '2',
alert: 'R2',
state: RuletypesAlertStateDTO.inactive,
labels: { severity: 'warning' },
});
const r3 = makeRule({
id: '3',
alert: 'R3',
state: RuletypesAlertStateDTO.firing,
labels: { severity: 'warning' },
});
const rules = [r1, r2, r3];
describe('isSameRuleListFilter', () => {
const filter: RuleListFilter = {
query: "severity = 'critical'",
states: ['firing', 'pending'],
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.asc,
};
it('returns input when filters empty', () => {
expect(filterRulesByFilters(rules, [])).toStrictEqual(rules);
it('ignores surrounding whitespace in the query', () => {
expect(
isSameRuleListFilter(filter, { ...filter, query: ` ${filter.query} ` }),
).toBe(true);
});
it('filters by state', () => {
const result = filterRulesByFilters(rules, ['state:firing']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
it('ignores states order', () => {
expect(
isSameRuleListFilter(filter, { ...filter, states: ['pending', 'firing'] }),
).toBe(true);
});
it('filters by severity', () => {
const result = filterRulesByFilters(rules, ['severity:warning']);
expect(result.map((r) => r.id)).toStrictEqual(['2', '3']);
it.each<[string, Partial<RuleListFilter>]>([
['query', { query: "severity = 'warning'" }],
['states', { states: ['firing'] }],
['sort', { sort: RuletypesListSortDTO.state }],
['order', { order: RuletypesListOrderDTO.desc }],
])('detects a different %s', (_, change) => {
expect(isSameRuleListFilter(filter, { ...filter, ...change })).toBe(false);
});
});
describe('isDefaultRuleListFilter', () => {
it('is true for DEFAULT_RULE_LIST_FILTER', () => {
expect(isDefaultRuleListFilter(DEFAULT_RULE_LIST_FILTER)).toBe(true);
});
it('combines state AND severity', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'severity:warning',
]);
expect(result.map((r) => r.id)).toStrictEqual(['3']);
it('is true for a whitespace-only query', () => {
expect(
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, query: ' ' }),
).toBe(true);
});
it('OR within same key (state)', () => {
const result = filterRulesByFilters(rules, [
'state:firing',
'state:inactive',
]);
expect(result.map((r) => r.id)).toStrictEqual(['1', '2', '3']);
});
it('matches values case-insensitively', () => {
const result = filterRulesByFilters(rules, ['state:FIRING']);
expect(result.map((r) => r.id)).toStrictEqual(['1', '3']);
});
it('ignores prefixes with wrong case (state: is required lowercase)', () => {
const result = filterRulesByFilters(rules, ['STATE:FIRING']);
expect(result).toStrictEqual(rules);
});
it('returns empty when no rule matches', () => {
expect(filterRulesByFilters(rules, ['state:nonexistent'])).toStrictEqual([]);
});
it('ignores unknown prefix', () => {
expect(filterRulesByFilters(rules, ['foo:bar'])).toStrictEqual(rules);
it('is false once any field differs', () => {
expect(
isDefaultRuleListFilter({ ...DEFAULT_RULE_LIST_FILTER, states: ['firing'] }),
).toBe(false);
expect(
isDefaultRuleListFilter({
...DEFAULT_RULE_LIST_FILTER,
sort: RuletypesListSortDTO.name,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,567 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
import {
alertRulesFixture,
listRulesV3Response,
ruleViewsFixture,
} from 'mocks-server/__mockdata__/alert_rules';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { getCurrentNuqsSearchParams } from 'tests/nuqs-helpers';
import {
getAppContextMock,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import { renderListAlertRules, runSearchQuery } from './_helpers';
// jsdom has no layout, and Floating UI's getComputedStyle walk over every
// popover ancestor costs ~1 s per open here, enough to time out under load.
jest.mock('@floating-ui/dom', () => ({
...jest.requireActual('@floating-ui/dom'),
autoUpdate: (): (() => void) => (): void => {},
computePosition: (
_reference: unknown,
_floating: unknown,
{ placement = 'bottom', strategy = 'absolute' } = {},
): Promise<unknown> =>
Promise.resolve({ x: 0, y: 0, placement, strategy, middlewareData: {} }),
}));
const VIEW_1_ORDER_BY = JSON.stringify({ columnName: 'name', order: 'asc' });
const VIEW_1_QUERY = "severity = 'critical'";
type Body = Record<string, unknown>;
type User = ReturnType<typeof userEvent.setup>;
function captureRulesRequests(): URLSearchParams[] {
const requests: URLSearchParams[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
);
}),
);
return requests;
}
function captureViewWrites(): {
posts: Body[];
puts: { id: string; body: Body }[];
deletes: string[];
} {
const captured = {
posts: [] as Body[],
puts: [] as { id: string; body: Body }[],
deletes: [] as string[],
};
server.use(
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
const body = (await req.json()) as Body;
captured.posts.push(body);
return res(
ctx.status(201),
ctx.json({
data: { ...body, id: 'new-view-id', orgId: 'org-1' },
status: 'success',
}),
);
}),
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
const body = (await req.json()) as Body;
const id = req.params.id as string;
captured.puts.push({ id, body });
return res(
ctx.status(200),
ctx.json({ data: { ...body, id, orgId: 'org-1' }, status: 'success' }),
);
}),
rest.delete('http://localhost/api/v2/rule_views/:id', (req, res, ctx) => {
captured.deletes.push(req.params.id as string);
return res(ctx.status(204));
}),
);
return captured;
}
function lastRequest(requests: URLSearchParams[]): URLSearchParams {
return requests[requests.length - 1];
}
function urlParam(key: string): string | null {
return getCurrentNuqsSearchParams().get(key);
}
// The linked-view restore writes the URL a tick after the views render.
function urlSettled(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function viewItem(id: string): HTMLElement {
return screen.getByTestId(`alert-rules-view-${id}`);
}
function activeHeading(): HTMLElement {
return screen.getByTestId('alert-rules-list-heading');
}
async function selectView(user: User, id: string): Promise<void> {
await user.click(await screen.findByTestId(`alert-rules-view-${id}`));
await waitFor(() => expect(urlParam('view')).toBe(id));
}
// A role query walks the whole table; the antd confirm's button is found by text.
function findConfirmDelete(): Promise<HTMLElement> {
return screen.findByText('Delete', {
selector: '.ant-modal-confirm button span',
});
}
describe('ListAlertRules — saved views rail', () => {
beforeEach(() => {
jest.setSystemTime(new Date('2023-10-20T12:00:00Z'));
localStorage.removeItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS);
useAlertRuleViewsStore.setState({ railCollapsed: false });
});
describe('builtin views', () => {
it('renders the builtin views with All active by default', async () => {
renderListAlertRules();
await screen.findByText('High CPU Alert');
expect(viewItem('mine')).toHaveTextContent('My alert rules');
expect(viewItem('all')).toHaveTextContent('All alert rules');
expect(viewItem('firing')).toHaveTextContent('Firing');
expect(viewItem('disabled')).toHaveTextContent('Disabled');
expect(activeHeading()).toHaveTextContent('All alert rules');
expect(
screen.queryByTestId('alert-rules-view-save-as-new'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('alert-rules-view-reset'),
).not.toBeInTheDocument();
});
it.each([
['firing', 'Firing'],
['disabled', 'Disabled'],
])('%s sends states=%s', async (id, label) => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await selectView(user, id);
await waitFor(() => {
expect(lastRequest(requests).getAll('states')).toStrictEqual([id]);
});
expect(lastRequest(requests).has('query')).toBe(false);
expect(urlParam('states')).toBe(id);
expect(activeHeading()).toHaveTextContent(label);
expect(
screen.queryByTestId('alert-rules-view-save-as-new'),
).not.toBeInTheDocument();
});
it('My alert rules filters by the current user as creator', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
const { email } = getAppContextMock('ADMIN').user;
renderListAlertRules();
await screen.findByText('High CPU Alert');
await selectView(user, 'mine');
const expected = `created_by = '${email}'`;
await waitFor(() => {
expect(lastRequest(requests).get('query')).toBe(expected);
});
expect(lastRequest(requests).has('states')).toBe(false);
expect(urlParam('query')).toBe(expected);
expect(activeHeading()).toHaveTextContent('My alert rules');
});
it('editing a builtin view filter offers save as new view, which POSTs', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-as-new'));
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
await user.type(
await screen.findByTestId('save-alert-rule-view-name'),
'Foo alerts',
);
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toStrictEqual({
name: 'Foo alerts',
data: {
version: 'v1',
query: 'foo',
states: [],
sort: 'updated_at',
order: 'desc',
},
});
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
});
});
describe('saved views', () => {
it('lists saved views and applies the selected one', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const requests = captureRulesRequests();
renderListAlertRules({ initialRoute: '/?page=2' });
await expect(
screen.findByTestId('alert-rules-view-view-1'),
).resolves.toHaveTextContent('Critical firing');
expect(viewItem('view-2')).toHaveTextContent('Infra team');
await selectView(user, 'view-1');
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
});
expect(urlParam('page')).not.toBe('2');
await waitFor(() => {
const last = lastRequest(requests);
expect(last.get('query')).toBe(VIEW_1_QUERY);
expect(last.getAll('states')).toStrictEqual(['firing']);
expect(last.get('sort')).toBe('name');
expect(last.get('order')).toBe('asc');
expect(last.get('offset')).toBe('0');
});
expect(activeHeading()).toHaveTextContent('Critical firing');
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
});
it('shows the unsaved-changes panel after editing an applied view filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await screen.findByTestId('alert-rules-view-save-changes');
expect(screen.getByTestId('alert-rules-view-save-as')).toBeInTheDocument();
expect(screen.getByTestId('alert-rules-view-reset')).toBeInTheDocument();
expect(
within(viewItem('view-1')).getByTitle('Unsaved changes'),
).toBeInTheDocument();
});
it('Save PUTs the full current filter and the unchanged name', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-changes'));
await waitFor(() => expect(captured.puts).toHaveLength(1));
expect(captured.puts[0]).toStrictEqual({
id: 'view-1',
body: {
name: 'Critical firing',
data: {
version: 'v1',
query: 'foo',
states: ['firing'],
sort: 'name',
order: 'asc',
},
},
});
await waitFor(() =>
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument(),
);
});
it('Save as POSTs a new view and activates it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await user.click(await screen.findByTestId('alert-rules-view-save-as'));
await user.type(
await screen.findByTestId('save-alert-rule-view-name'),
'My view',
);
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toStrictEqual({
name: 'My view',
data: {
version: 'v1',
query: 'foo',
states: ['firing'],
sort: 'name',
order: 'asc',
},
});
await waitFor(() => expect(urlParam('view')).toBe('new-view-id'));
expect(captured.puts).toHaveLength(0);
});
it('caps a view name at 64 characters', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await screen.findByText('High CPU Alert');
await user.click(screen.getByLabelText('Save current filters as a view'));
const input = await screen.findByTestId('save-alert-rule-view-name');
await user.type(input, 'x'.repeat(70));
expect(input).toHaveValue('x'.repeat(64));
await user.click(screen.getByTestId('save-alert-rule-view-confirm'));
await waitFor(() => expect(captured.posts).toHaveLength(1));
expect(captured.posts[0]).toMatchObject({ name: 'x'.repeat(64) });
});
it('Reset restores the active view filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await runSearchQuery(user, 'foo');
await waitFor(() => expect(urlParam('query')).toBe('foo'));
await user.click(await screen.findByTestId('alert-rules-view-reset'));
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
});
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
expect(urlParam('view')).toBe('view-1');
expect(
screen.queryByTestId('alert-rules-view-reset'),
).not.toBeInTheDocument();
});
it('rename PUTs the new name and keeps the stored filter', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-2');
await runSearchQuery(user, 'foo');
await screen.findByTestId('alert-rules-view-save-changes');
const row = viewItem('view-1').parentElement as HTMLElement;
await user.click(within(row).getByLabelText('Rename view'));
const input = await screen.findByTestId('rename-alert-rule-view-name');
expect(input).toHaveValue('Critical firing');
await user.clear(input);
await user.type(input, 'Renamed');
await user.click(screen.getByTestId('rename-alert-rule-view-confirm'));
await waitFor(() => expect(captured.puts).toHaveLength(1));
expect(captured.puts[0]).toStrictEqual({
id: 'view-1',
body: {
name: 'Renamed',
data: {
version: 'v1',
query: VIEW_1_QUERY,
states: ['firing'],
sort: 'name',
order: 'asc',
},
},
});
await waitFor(() => expect(viewItem('view-1')).toHaveTextContent('Renamed'));
});
it('delete asks for confirmation and sends DELETE', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await user.click(screen.getByTestId('alert-rules-view-delete-view-2'));
await expect(
screen.findByText(/Views are shared across your organization/),
).resolves.toBeInTheDocument();
expect(captured.deletes).toHaveLength(0);
await user.click(await findConfirmDelete());
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-2']));
expect(urlParam('view')).toBe('view-1');
});
it('deleting the active view falls back to All', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
const captured = captureViewWrites();
renderListAlertRules();
await selectView(user, 'view-1');
await waitFor(() => expect(urlParam('query')).toBe(VIEW_1_QUERY));
await user.click(screen.getByTestId('alert-rules-view-delete-view-1'));
await user.click(await findConfirmDelete());
await waitFor(() => expect(captured.deletes).toStrictEqual(['view-1']));
await waitFor(() => {
expect(urlParam('view')).toBeNull();
expect(urlParam('query')).toBeNull();
expect(urlParam('states')).toBeNull();
});
expect(activeHeading()).toHaveTextContent('All alert rules');
});
it('All alert rules clears the view and its filters', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-1');
await waitFor(() => expect(urlParam('query')).not.toBeNull());
await user.click(viewItem('all'));
await waitFor(() => {
expect(urlParam('view')).toBeNull();
expect(urlParam('query')).toBeNull();
expect(urlParam('states')).toBeNull();
});
expect(activeHeading()).toHaveTextContent('All alert rules');
});
});
describe('shared links', () => {
it('applies the linked view filter when the URL carries only view=', async () => {
renderListAlertRules({ initialRoute: '/?view=view-1' });
await waitFor(() => {
expect(urlParam('query')).toBe(VIEW_1_QUERY);
expect(urlParam('states')).toBe('firing');
expect(urlParam('orderBy')).toBe(VIEW_1_ORDER_BY);
});
expect(urlParam('view')).toBe('view-1');
expect(activeHeading()).toHaveTextContent('Critical firing');
expect(
screen.queryByTestId('alert-rules-view-save-changes'),
).not.toBeInTheDocument();
});
it('keeps explicit filter params from the link and marks the view modified', async () => {
renderListAlertRules({ initialRoute: '/?view=view-1&query=foo' });
await screen.findByTestId('alert-rules-view-save-changes');
expect(urlParam('query')).toBe('foo');
expect(urlParam('states')).toBeNull();
expect(activeHeading()).toHaveTextContent('Critical firing');
});
it('marks a cleared view filter in the URL', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await selectView(user, 'view-2');
await user.click(screen.getByTestId('alert-rules-filter-clear'));
await waitFor(() => expect(urlParam('query')).toBe(''));
expect(urlParam('view')).toBe('view-2');
});
it('keeps a cleared view filter cleared on reload', async () => {
const requests = captureRulesRequests();
renderListAlertRules({ initialRoute: '/?view=view-1&query=' });
await screen.findByTestId('alert-rules-view-save-changes');
await urlSettled();
expect(urlParam('query')).toBe('');
expect(urlParam('states')).toBeNull();
expect(lastRequest(requests).get('query') ?? '').toBe('');
});
it('keeps a query run while the linked views are still loading', async () => {
server.use(
rest.get('http://localhost/api/v2/rule_views', (_, res, ctx) =>
res(
ctx.delay(300),
ctx.status(200),
ctx.json({ data: { views: ruleViewsFixture }, status: 'success' }),
),
),
);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules({ initialRoute: '/?view=view-1' });
await runSearchQuery(user, 'foo');
await screen.findByTestId(
'alert-rules-view-save-changes',
{},
{ timeout: 5000 },
);
await urlSettled();
expect(urlParam('query')).toBe('foo');
expect(urlParam('states')).toBeNull();
});
});
describe('rail chrome', () => {
it('filters views by name', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await screen.findByTestId('alert-rules-view-view-2');
const search = screen.getByTestId('alert-rules-view-search');
await user.type(search, 'infra');
expect(viewItem('view-2')).toBeInTheDocument();
expect(
screen.queryByTestId('alert-rules-view-view-1'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('alert-rules-view-all')).not.toBeInTheDocument();
await user.clear(search);
await user.type(search, 'zzz');
expect(screen.getByText(/No views match/)).toBeInTheDocument();
});
it('collapses the rail from the status bar and persists it', async () => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
renderListAlertRules();
await screen.findByText('High CPU Alert');
const toggle = screen.getByTestId('alert-rules-rail-toggle');
expect(toggle).toHaveTextContent('Collapse');
await user.click(toggle);
expect(toggle).toHaveTextContent('Expand');
const stored = JSON.parse(
localStorage.getItem(LOCALSTORAGE.ALERT_RULES_LIST_VIEWS) ?? '{}',
);
expect(stored.state.railCollapsed).toBe(true);
});
});
});

View File

@@ -1,6 +1,9 @@
import { type ReactNode, useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, RenderResult, screen } from '@testing-library/react';
import { act, render, RenderResult, screen } from '@testing-library/react';
import type userEvent from '@testing-library/user-event';
import { EditorView } from '@uiw/react-codemirror';
import ListAlertRules from 'container/ListAlertRules';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { AppContext } from 'providers/App/App';
@@ -9,6 +12,32 @@ import { onNuqsUrlUpdate, resetNuqsState } from 'tests/nuqs-helpers';
import { TestRouter } from 'tests/router';
import { getAppContextMock } from 'tests/test-utils';
// NuqsTestingAdapter resets its update queue on every render, not only on mount,
// dropping writes queued just before it re-renders.
function UrlAdapter({
searchParams,
children,
}: {
searchParams: string;
children: ReactNode;
}): JSX.Element {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<NuqsTestingAdapter
searchParams={searchParams}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
resetUrlUpdateQueueOnMount={!mounted}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
}
interface RenderOptions {
role?: string;
initialRoute?: string;
@@ -33,12 +62,7 @@ export function renderListAlertRules(
return render(
<TestRouter initialRoute={initialRoute}>
<NuqsTestingAdapter
searchParams={initialSearch}
onUrlUpdate={onNuqsUrlUpdate}
rateLimitFactor={0}
hasMemory
>
<UrlAdapter searchParams={initialSearch}>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role)}>
<TimezoneProvider>
@@ -50,7 +74,7 @@ export function renderListAlertRules(
</TimezoneProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsTestingAdapter>
</UrlAdapter>
</TestRouter>,
);
}
@@ -63,3 +87,31 @@ export async function findAlertRow(alertName: string): Promise<HTMLElement> {
}
return row as HTMLElement;
}
type User = ReturnType<typeof userEvent.setup>;
export function setSearchDraft(value: string): void {
const view = EditorView.findFromDOM(
screen.getByTestId('alert-rules-list-search'),
);
if (!view) {
throw new Error('Search editor not found');
}
act(() => {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
});
}
export function getSearchDraft(): string {
const view = EditorView.findFromDOM(
screen.getByTestId('alert-rules-list-search'),
);
return view?.state.doc.toString() ?? '';
}
export async function runSearchQuery(user: User, value: string): Promise<void> {
setSearchDraft(value);
await user.click(screen.getByTestId('alert-rules-list-search-submit'));
}

View File

@@ -7,7 +7,7 @@ import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
deleteRuleByID,
invalidateListRules,
invalidateListRulesV3,
patchRuleByID,
cloneRuleByID,
} from 'api/generated/services/rules';
@@ -22,7 +22,7 @@ import { ALERT_ACTIONS, alertActionLogEvent } from '../utils';
interface ActionsMenuProps {
rule: AlertRule;
onEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
onEdit: (rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }) => void;
}
function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
@@ -34,7 +34,9 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
toast.promise(
patchRuleByID({ id: rule.id ?? '' }, {
disabled: newDisabled,
} as RuletypesPostableRuleDTO).then(() => invalidateListRules(queryClient)),
} as RuletypesPostableRuleDTO).then(() =>
invalidateListRulesV3(queryClient),
),
{
loading: newDisabled ? 'Disabling alert...' : 'Enabling alert...',
success: newDisabled ? 'Alert disabled' : 'Alert enabled',
@@ -65,10 +67,10 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
cloneRuleByID({
id: rule.id,
}).then(async (response) => {
await invalidateListRules(queryClient);
await invalidateListRulesV3(queryClient);
const newRule = response.data;
if (newRule) {
onEdit(newRule as AlertRule);
onEdit(newRule);
}
return newRule;
}),
@@ -90,7 +92,7 @@ function ActionsMenu({ rule, onEdit }: ActionsMenuProps): JSX.Element {
alertActionLogEvent(ALERT_ACTIONS.DELETE, rule);
toast.promise(
deleteRuleByID({ id: rule.id ?? '' }).then(() =>
invalidateListRules(queryClient),
invalidateListRulesV3(queryClient),
),
{
loading: 'Deleting alert...',

View File

@@ -0,0 +1,44 @@
import FilterZone from 'components/ListFilter/FilterZone/FilterZone';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type { CreatorOption } from 'components/ListFilter/types';
import { RULES_DSL_GRAMMAR } from '../list.config';
import RuleStatesFilter from './RuleStatesFilter';
interface Props {
query: string;
states: string[];
creatorOptions: CreatorOption[];
source: SuggestionSource;
error: string | null;
onQueryChange: (query: string) => void;
onStatesChange: (states: string[]) => void;
}
function AlertRulesFilterZone({
query,
states,
creatorOptions,
source,
error,
onQueryChange,
onStatesChange,
}: Props): JSX.Element {
return (
<FilterZone
query={query}
grammar={RULES_DSL_GRAMMAR}
testIdPrefix="alert-rules"
placeholder="DSL Filter — e.g. name CONTAINS 'cpu' AND labels.team = 'infra'"
creatorOptions={creatorOptions}
source={source}
onQueryChange={onQueryChange}
extraFilters={<RuleStatesFilter states={states} onChange={onStatesChange} />}
hasExtraFilters={states.length > 0}
onClearAll={(): void => onStatesChange([])}
error={error ?? undefined}
/>
);
}
export default AlertRulesFilterZone;

View File

@@ -0,0 +1,5 @@
.actions {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,51 @@
import type { MouseEvent } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { ColumnSelector } from 'components/TanStackTableView';
import type { TableColumnDef } from 'components/TanStackTableView';
import TextToolTip from 'components/TextToolTip';
import type { AlertRule } from '../types';
import styles from './AlertRulesHeaderActions.module.scss';
interface Props {
columns: TableColumnDef<AlertRule>[];
canAddAlert: boolean;
onNewAlert: (e: MouseEvent) => void;
}
function AlertRulesHeaderActions({
columns,
canAddAlert,
onNewAlert,
}: Props): JSX.Element {
return (
<div className={styles.actions}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
/>
{canAddAlert && (
<Button
variant="solid"
size="sm"
prefix={<Plus size={14} />}
onClick={onNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
)}
<TextToolTip
text="More details on how to create alerts"
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
urlText="Learn More"
/>
</div>
);
}
export default AlertRulesHeaderActions;

View File

@@ -0,0 +1,4 @@
.statesSelect {
width: 240px;
flex: none;
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { ComboboxSimple, type ComboboxSimpleItem } from '@signozhq/ui/combobox';
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './RuleStatesFilter.module.scss';
const STATE_ITEMS: ComboboxSimpleItem[] = [
{ value: RuletypesAlertStateDTO.firing, label: 'Firing' },
{ value: RuletypesAlertStateDTO.pending, label: 'Pending' },
{ value: RuletypesAlertStateDTO.recovering, label: 'Recovering' },
{ value: RuletypesAlertStateDTO.inactive, label: 'OK' },
{ value: RuletypesAlertStateDTO.nodata, label: 'No data' },
{ value: RuletypesAlertStateDTO.disabled, label: 'Disabled' },
];
interface RuleStatesFilterProps {
states: string[];
onChange: (states: string[]) => void;
}
function RuleStatesFilter({
states,
onChange,
}: RuleStatesFilterProps): JSX.Element {
const handleChange = useCallback(
(values: unknown): void => {
if (Array.isArray(values)) {
onChange(values as string[]);
}
},
[onChange],
);
return (
<ComboboxSimple
className={styles.statesSelect}
multiple
value={states}
onChange={handleChange}
placeholder="Filter by state"
inputPlaceholder="Select one or more"
items={STATE_ITEMS}
maxDisplayedPills={2}
testId="alert-rules-filter-states"
/>
);
}
export default RuleStatesFilter;

View File

@@ -1,24 +1,34 @@
import { Options, useQueryState, UseQueryStateReturn } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
Options,
parseAsArrayOf,
parseAsString,
useQueryState,
UseQueryStateReturn,
} from 'nuqs';
const defaultNuqsOptions: Options = {
history: 'push',
};
export const ALERT_RULES_PARAMS = {
SEARCH: 'search',
QUERY: 'query',
STATES: 'states',
VIEW: 'view',
PAGE: 'page',
RULE_TYPE: 'ruleType',
FILTERS: 'alertRulesFilters',
} as const;
export const useAlertRulesFilters = (): UseQueryStateReturn<
export const useAlertRulesQuery = (): UseQueryStateReturn<string, string> =>
useQueryState(
ALERT_RULES_PARAMS.QUERY,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
export const useAlertRulesStates = (): UseQueryStateReturn<
string[],
string[]
> =>
useQueryState(
ALERT_RULES_PARAMS.FILTERS,
parseAsJsonNoValidate<string[]>()
.withDefault([])
.withOptions(defaultNuqsOptions),
ALERT_RULES_PARAMS.STATES,
parseAsArrayOf(parseAsString).withDefault([]).withOptions(defaultNuqsOptions),
);

View File

@@ -1,28 +1,31 @@
import { useCallback, useMemo } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
import ListCommandHeader from 'components/ListViews/ListCommandHeader/ListCommandHeader';
import ListStatusBar from 'components/ListViews/ListStatusBar/ListStatusBar';
import ViewsRail from 'components/ListViews/ViewsRail/ViewsRail';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import useComponentPermission from 'hooks/useComponentPermission';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import TextToolTip from 'components/TextToolTip';
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
import { ActionsMenu } from './components';
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import AlertRulesFilterZone from './components/AlertRulesFilterZone';
import AlertRulesHeaderActions from './components/AlertRulesHeaderActions';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
import type { AlertRule } from './types';
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
import { useAlertRulesData } from './useAlertRulesData';
import { useAlertRulesFilterSource } from './useAlertRulesFilterSource';
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
import { useAlertRulesListFilter } from './useAlertRulesListFilter';
import { useAlertRuleViews } from './useAlertRuleViews';
import { useAlertRuleViewsStore } from './useAlertRuleViewsStore';
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
@@ -40,14 +43,13 @@ function ListAlertRules(): JSX.Element {
user.role,
);
const [filterValues, setFilterValues] = useAlertRulesFilters();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
const { orderBy, page, limit, setLimit, setPage, setOrderBy } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
@@ -58,39 +60,63 @@ function ListAlertRules(): JSX.Element {
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { filter, applyFilter, submitQuery, changeStates } =
useAlertRulesListFilter({ orderBy, setOrderBy, setPage });
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(ALERT_RULES_PARAMS.SEARCH, {
onDebouncedChange: resetPageOnSearch,
});
const ruleViews = useAlertRuleViews({ filter, applyFilter });
const { filteredRules, isFetching, isError, allRules, refetch } =
useAlertRulesData(orderBy, debouncedSearch, filterValues ?? []);
const railCollapsed = useAlertRuleViewsStore((s) => s.railCollapsed);
const setRailCollapsed = useAlertRuleViewsStore((s) => s.setRailCollapsed);
const toggleRail = useCallback((): void => {
setRailCollapsed(!railCollapsed);
}, [setRailCollapsed, railCollapsed]);
const listParams = useMemo(
() => ({
query: filter.query || undefined,
states: filter.states.length ? filter.states : undefined,
sort: filter.sort,
order: filter.order,
limit,
offset: (page - 1) * limit,
}),
[filter, limit, page],
);
const {
rules,
total,
labels,
reservedKeywords,
isFetching,
isError,
queryError,
refetch,
} = useAlertRulesData(listParams);
const { creatorOptions, source } = useAlertRulesFilterSource({
rules,
labels,
reservedKeywords,
userEmail: user.email,
});
const { handleEdit, handleNewAlert, handleRowClick, handleRowClickNewTab } =
useAlertRulesHandlers(allRules.length);
useAlertRulesHandlers(total);
const { selectView } = ruleViews;
const handleClearFilters = useCallback((): void => {
void setFilterValues(null);
clearSearch();
}, [setFilterValues, clearSearch]);
selectView(DEFAULT_RULE_VIEW);
}, [selectView]);
const columns = useMemo(
() => getAlertRuleColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const paginatedRules = useMemo(() => {
const start = (page - 1) * limit;
return filteredRules.slice(start, start + limit);
}, [filteredRules, page, limit]);
useAlertRulesStripInfo({
shownCount: paginatedRules.length,
totalCount: filteredRules.length,
shownCount: rules.length,
totalCount: total,
});
const columnsWithActions = useMemo(() => {
@@ -120,93 +146,113 @@ function ListAlertRules(): JSX.Element {
];
}, [action, columns, handleEdit]);
const hasActiveFilters =
searchText.length > 0 || (filterValues ?? []).length > 0;
const hasActiveFilters = filter.query.length > 0 || filter.states.length > 0;
const isEmptyDueToFilters =
!isFetching &&
filteredRules.length === 0 &&
hasActiveFilters &&
allRules.length > 0;
const isEmptyNoRules = !isFetching && !isError && allRules.length === 0;
!isFetching && total === 0 && (hasActiveFilters || !!queryError);
const isEmptyNoRules =
!isFetching && !isError && !queryError && !hasActiveFilters && total === 0;
const activeLabel =
ruleViews.activeView?.name ??
RULE_BUILTIN_VIEWS.find((v) => v.id === ruleViews.activeViewId)?.label ??
'Alert rules';
return (
<div className={styles.container}>
{!isEmptyNoRules && (
<div className={styles.header}>
<div className={styles.refreshRow}>
<ColumnSelector
columns={columns}
storageKey="alert-rules-columns"
testId="alert-columns-button"
<div className={styles.layout}>
<ViewsRail
activeViewId={ruleViews.activeViewId}
builtinViews={RULE_BUILTIN_VIEWS}
customViews={ruleViews.customViews}
customViewsLoading={ruleViews.customViewsLoading}
isCustomActive={ruleViews.isCustomActive}
isModified={ruleViews.isModified}
collapsed={railCollapsed}
onSelect={ruleViews.selectView}
onSave={ruleViews.saveView}
onSaveChanges={ruleViews.saveActiveView}
onReset={ruleViews.resetView}
onDelete={ruleViews.removeView}
onRename={ruleViews.renameView}
testIdPrefix="alert-rules"
saveTestIdPrefix="save-alert-rule-view"
renameTestIdPrefix="rename-alert-rule-view"
deleteDescription="Views are shared across your organization, so this removes it for everyone. Alert rules are not affected."
/>
<div className={styles.main}>
{!isEmptyNoRules && (
<div className={styles.headerZone}>
<ListCommandHeader
label={activeLabel}
count={total}
isModified={ruleViews.isModified}
testIdPrefix="alert-rules"
action={
<AlertRulesHeaderActions
columns={columns}
canAddAlert={addNewAlert}
onNewAlert={handleNewAlert}
/>
}
/>
{addNewAlert && (
<Button
variant="solid"
size="sm"
prefix={<Plus size={14} />}
onClick={handleNewAlert}
color="primary"
testId="list-alerts-new-alert-button"
>
New Alert
</Button>
)}
<TextToolTip
text="More details on how to create alerts"
url="https://signoz.io/docs/alerts/?utm_source=product&utm_medium=list-alerts"
urlText="Learn More"
<AlertRulesFilterZone
query={filter.query}
states={filter.states}
creatorOptions={creatorOptions}
source={source}
error={queryError}
onQueryChange={submitQuery}
onStatesChange={changeStates}
/>
</div>
</div>
)}
{!isEmptyNoRules && (
<div className={styles.filtersRow}>
<Input
className={styles.searchInput}
placeholder="Search by Alert Name, Severity and Labels"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="list-alerts-search-input"
/>
</div>
)}
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alert rules"
subtitle="No alert rules match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmptyNoRules ? (
<AlertsEmptyState onRefresh={refetch} />
) : (
<TanStackTable<AlertRule>
data={paginatedRules}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id ?? ''}
getItemKey={(row): string => row.id ?? ''}
columnStorageKey="alert-rules-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total: filteredRules.length,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState title="Failed to load alert rules" onRefresh={refetch} />
) : isEmptyDueToFilters ? (
<NoResultsEmptyState
title="No matching alert rules"
subtitle={
queryError
? 'The filter query is invalid. Fix it or clear the search.'
: 'No alert rules match your search. Try adjusting your search criteria.'
}
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : isEmptyNoRules ? (
<AlertsEmptyState onRefresh={refetch} />
) : (
<TanStackTable<AlertRule>
data={rules}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id ?? ''}
getItemKey={(row): string => row.id ?? ''}
columnStorageKey="alert-rules-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={2}
/>
)}
</div>
<ListStatusBar
collapsed={railCollapsed}
onToggleCollapse={toggleRail}
count={rules.length}
total={total}
noun="alert rules"
testId="alert-rules-rail-toggle"
/>
</div>
</div>
);

View File

@@ -0,0 +1,97 @@
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
import { BellOff, Flame, Layers, User } from '@signozhq/icons';
import { clause } from 'components/ListFilter/dsl/filterQuery';
import type {
ClauseBuilder,
DslGrammar,
} from 'components/ListFilter/dsl/types';
import type { BuiltinViewConfig } from 'components/ListViews/types';
import type { RuleListFilter, RuleViewCtx } from './types';
// Mirrors pkg/types/ruletypes/list_filter.go. Label keys go under `labels.`.
export const RULES_DSL_GRAMMAR = {
fields: [
{ key: 'name', type: 'string' },
// Aliases labels.severity, so it takes the label operators.
{ key: 'severity', type: 'dynamic' },
{
key: 'alert_type',
type: 'enum',
values: [
'METRIC_BASED_ALERT',
'LOGS_BASED_ALERT',
'TRACES_BASED_ALERT',
'EXCEPTIONS_BASED_ALERT',
'AI_TRACES_BASED_ALERT',
],
},
{
key: 'rule_type',
type: 'enum',
values: ['threshold_rule', 'promql_rule', 'anomaly_rule'],
},
{ key: 'created_by', type: 'string' },
{ key: 'updated_by', type: 'string' },
{ key: 'created_at', type: 'timestamp' },
{ key: 'updated_at', type: 'timestamp' },
],
dynamicKey: { detail: 'label', prefix: 'labels.' },
hiddenKeys: ['labels.<key>'],
} as const satisfies DslGrammar;
const where: ClauseBuilder<
(typeof RULES_DSL_GRAMMAR)['fields'][number]['key']
> = clause;
type RuleViewConfig = BuiltinViewConfig<RuleListFilter, RuleViewCtx>;
// Built-in views replace query and states; the current sort/order is kept.
const views = [
{
id: 'mine',
label: 'My alert rules',
icon: User,
section: 'personal',
filter: (current, { userEmail }): RuleListFilter => ({
...current,
query: userEmail ? where('created_by', '=', userEmail) : '',
states: [],
}),
},
{
id: 'all',
label: 'All alert rules',
icon: Layers,
section: 'system',
filter: (current): RuleListFilter => ({ ...current, query: '', states: [] }),
},
{
id: 'firing',
label: 'Firing',
icon: Flame,
section: 'system',
filter: (current): RuleListFilter => ({
...current,
query: '',
states: [RuletypesAlertStateDTO.firing],
}),
},
{
id: 'disabled',
label: 'Disabled',
icon: BellOff,
section: 'system',
filter: (current): RuleListFilter => ({
...current,
query: '',
states: [RuletypesAlertStateDTO.disabled],
}),
},
] as const satisfies readonly RuleViewConfig[];
export type RuleViewId = (typeof views)[number]['id'];
export const RULE_BUILTIN_VIEWS: readonly RuleViewConfig[] = views;
export const DEFAULT_RULE_VIEW: RuleViewId = 'all';

View File

@@ -1,3 +1,22 @@
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
RuletypesListableRuleDTO,
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SavedListView } from 'components/ListViews/types';
export type AlertRule = RuletypesRuleDTO;
export type AlertRule = RuletypesListableRuleDTO;
// The v3 list filter a saved view snapshots; pagination is deliberately excluded.
export interface RuleListFilter {
query: string;
states: string[];
sort: RuletypesListSortDTO;
order: RuletypesListOrderDTO;
}
export type RuleView = SavedListView<RuleListFilter>;
export interface RuleViewCtx {
userEmail: string;
}

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef } from 'react';
import { useQueryState } from 'nuqs';
import type { UseListViewsResult } from 'hooks/listViews/types';
import { useListViews } from 'hooks/listViews/useListViews';
import { ALERT_RULES_PARAMS } from './hooks';
import { DEFAULT_RULE_VIEW, RULE_BUILTIN_VIEWS } from './list.config';
import type { RuleListFilter, RuleViewCtx } from './types';
import { useRuleViewCtx } from './useRuleViewCtx';
import { useRuleViews } from './useRuleViews';
import { isDefaultRuleListFilter, isSameRuleListFilter } from './utils';
interface UseAlertRuleViewsArgs {
filter: RuleListFilter;
applyFilter: (next: RuleListFilter) => void;
}
export interface UseAlertRuleViewsResult extends UseListViewsResult<RuleListFilter> {
customViewsLoading: boolean;
}
// A link carrying only `view=<custom id>` gets that view's filter applied once
// the views load; a link carrying its own filter params keeps them. Clearing a
// custom view's filter writes an empty `query=` so a reload keeps it cleared.
export function useAlertRuleViews({
filter,
applyFilter,
}: UseAlertRuleViewsArgs): UseAlertRuleViewsResult {
const api = useRuleViews();
const ctx = useRuleViewCtx();
const views = useListViews<RuleListFilter, RuleViewCtx>({
filter,
applyFilter,
isEqual: isSameRuleListFilter,
builtinViews: RULE_BUILTIN_VIEWS,
ctx,
defaultViewId: DEFAULT_RULE_VIEW,
api,
urlParam: ALERT_RULES_PARAMS.VIEW,
});
const { activeView } = views;
const [rawQuery, setRawQuery] = useQueryState(ALERT_RULES_PARAMS.QUERY);
const startedFromViewLink = useRef(
isDefaultRuleListFilter(filter) && rawQuery === null,
);
const hasResolvedLinkedView = useRef(false);
// Declared before the restore so it skips the render that applies a linked view.
const clearedCustomView =
!!activeView && rawQuery === null && isDefaultRuleListFilter(filter);
useEffect(() => {
if (hasResolvedLinkedView.current && clearedCustomView) {
void setRawQuery('', { clearOnDefault: false, history: 'replace' });
}
}, [clearedCustomView, setRawQuery]);
useEffect(() => {
if (hasResolvedLinkedView.current || api.isLoading) {
return;
}
hasResolvedLinkedView.current = true;
if (
activeView &&
startedFromViewLink.current &&
isDefaultRuleListFilter(filter)
) {
applyFilter(activeView.filter);
}
}, [api.isLoading, activeView, applyFilter, filter]);
return { ...views, customViewsLoading: api.isLoading };
}

View File

@@ -0,0 +1,20 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { LOCALSTORAGE } from 'constants/localStorage';
interface AlertRuleViewsState {
railCollapsed: boolean;
setRailCollapsed: (collapsed: boolean) => void;
}
export const useAlertRuleViewsStore = create<AlertRuleViewsState>()(
persist(
(set) => ({
railCollapsed: false,
setRailCollapsed: (collapsed): void => {
set({ railCollapsed: collapsed });
},
}),
{ name: LOCALSTORAGE.ALERT_RULES_LIST_VIEWS },
),
);

View File

@@ -1,55 +1,92 @@
import { useEffect, useMemo, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import { useListRules } from 'api/generated/services/rules';
import { searchByLabels } from 'components/Alerts/utils';
import type { SortState } from 'components/TanStackTableView/types';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRulesV3 } from 'api/generated/services/rules';
import type {
ListRulesV3200,
ListRulesV3Params,
RenderErrorResponseDTO,
RuletypesLabelPairDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
import { isUndefined } from 'lodash-es';
import type { AlertRule } from './types';
import { filterRulesByFilters, sortRules } from './utils';
import { RULE_LIST_INVALID_ERROR_CODES } from './utils';
interface UseAlertRulesDataReturn {
allRules: AlertRule[];
filteredRules: AlertRule[];
rules: AlertRule[];
total: number;
/** Label pairs across the org's rules, for DSL autocomplete. */
labels: RuletypesLabelPairDTO[];
reservedKeywords: string[] | undefined;
isFetching: boolean;
/** A failure other than an invalid filter query. */
isError: boolean;
/** The backend's message when the filter query is invalid. */
queryError: string | null;
refetch: () => void;
}
export function useAlertRulesData(
orderBy: SortState | null,
searchText = '',
filters: string[] = [],
params: ListRulesV3Params,
): UseAlertRulesDataReturn {
const hasLoggedEvent = useRef(false);
const rulesResponse = useListRules();
const rulesResponse = useListRulesV3(params, {
query: {
keepPreviousData: true,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const allRules = useMemo(
() => rulesResponse.data?.data ?? [],
const rules = useMemo(
() => rulesResponse.data?.data.rules ?? [],
[rulesResponse.data],
);
const total = rulesResponse.data?.data.total ?? 0;
const labels = useMemo(
() => rulesResponse.data?.data.labels ?? [],
[rulesResponse.data],
);
useEffect(() => {
if (!hasLoggedEvent.current && !isUndefined(rulesResponse.data?.data)) {
void logEvent('Alert: List page visited', {
number: allRules.length,
number: total,
});
hasLoggedEvent.current = true;
}
}, [rulesResponse.data, allRules.length]);
}, [rulesResponse.data, total]);
const filteredRules = useMemo(() => {
const filtered = filterRulesByFilters(allRules, filters);
const searched = searchByLabels(filtered, searchText, (r) => r.alert ?? '');
return sortRules(searched, orderBy);
}, [allRules, filters, searchText, orderBy]);
const apiError = useMemo(
() =>
convertToApiError(
rulesResponse.error as AxiosError<RenderErrorResponseDTO> | null,
),
[rulesResponse.error],
);
const errorCode = apiError?.getErrorCode();
const isQueryError =
!!errorCode && RULE_LIST_INVALID_ERROR_CODES.includes(errorCode);
return {
allRules,
filteredRules,
rules: isQueryError ? [] : rules,
total: isQueryError ? 0 : total,
labels,
reservedKeywords: rulesResponse.data?.data.reservedKeywords,
isFetching: rulesResponse.isFetching,
isError: rulesResponse.isError,
isError: rulesResponse.isError && !isQueryError,
queryError: isQueryError ? (apiError?.getErrorMessage() ?? null) : null,
refetch: rulesResponse.refetch,
};
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react';
import type { RuletypesLabelPairDTO } from 'api/generated/services/sigNoz.schemas';
import type { SuggestionSource } from 'components/ListFilter/dsl/types';
import type { CreatorOption } from 'components/ListFilter/types';
import { useCreatorOptions } from 'components/ListFilter/useCreatorOptions';
import type { AlertRule } from './types';
const LABEL_PREFIX = 'labels.';
interface UseAlertRulesFilterSourceArgs {
rules: AlertRule[];
labels: RuletypesLabelPairDTO[];
reservedKeywords: string[] | undefined;
userEmail: string;
}
interface UseAlertRulesFilterSourceResult {
creatorOptions: CreatorOption[];
source: SuggestionSource;
}
export function useAlertRulesFilterSource({
rules,
labels,
reservedKeywords,
userEmail,
}: UseAlertRulesFilterSourceArgs): UseAlertRulesFilterSourceResult {
const pageAuthorEmails = useMemo<string[]>(
() =>
rules.map((r) => r.createdBy).filter((email): email is string => !!email),
[rules],
);
const creatorOptions = useCreatorOptions({
currentUserEmail: userEmail,
fallbackEmails: pageAuthorEmails,
});
const source = useMemo<SuggestionSource>(() => {
const dynamicKeys = new Set<string>();
const valuesByKey: Record<string, string[]> = {};
labels.forEach(({ key, value }) => {
const fullKey = `${LABEL_PREFIX}${key}`;
dynamicKeys.add(fullKey);
(valuesByKey[fullKey.toLowerCase()] ??= []).push(value);
});
return {
reservedKeys: reservedKeywords,
dynamicKeys: Array.from(dynamicKeys),
valuesByKey,
creatorEmails: creatorOptions.map((o) => o.email),
currentUserEmail: userEmail,
};
}, [labels, reservedKeywords, creatorOptions, userEmail]);
return { creatorOptions, source };
}

View File

@@ -2,18 +2,18 @@ import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
import type { AlertRule } from './types';
interface UseAlertRulesHandlersReturn {
handleEdit: (rule: AlertRule, options?: { newTab?: boolean }) => void;
handleEdit: (
rule: Pick<AlertRule, 'id'>,
options?: { newTab?: boolean },
) => void;
handleNewAlert: (e: React.MouseEvent) => void;
handleRowClick: (rule: AlertRule) => void;
handleRowClickNewTab: (rule: AlertRule) => void;
@@ -26,30 +26,16 @@ export function useAlertRulesHandlers(
const params = useUrlQuery();
const getEditUrl = useCallback(
(rule: AlertRule): string => {
const compositeQuery = sanitizeDefaultAlertQuery(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
(rule: Pick<AlertRule, 'id'>): string => {
const editParams = new URLSearchParams(params);
editParams.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${editParams.toString()}`;
},
[params],
);
const handleEdit = useCallback(
(rule: AlertRule, options?: { newTab?: boolean }): void => {
(rule: Pick<AlertRule, 'id'>, options?: { newTab?: boolean }): void => {
safeNavigate(getEditUrl(rule), options);
},
[getEditUrl, safeNavigate],

View File

@@ -0,0 +1,62 @@
import { useCallback, useMemo } from 'react';
import type { SortState } from 'components/TanStackTableView/types';
import { useAlertRulesQuery, useAlertRulesStates } from './hooks';
import type { RuleListFilter } from './types';
import { toListSort, toOrderBy } from './utils';
interface UseAlertRulesListFilterArgs {
orderBy: SortState | null;
setOrderBy: (orderBy: SortState | null) => void;
setPage: (page: number) => void;
}
interface UseAlertRulesListFilterResult {
filter: RuleListFilter;
applyFilter: (filter: RuleListFilter) => void;
submitQuery: (query: string) => void;
changeStates: (states: string[]) => void;
}
// The v3 list filter, read from and written to the URL. Every change goes back to page 1.
export function useAlertRulesListFilter({
orderBy,
setOrderBy,
setPage,
}: UseAlertRulesListFilterArgs): UseAlertRulesListFilterResult {
const [query, setQuery] = useAlertRulesQuery();
const [states, setStates] = useAlertRulesStates();
const filter = useMemo<RuleListFilter>(
() => ({ query, states, ...toListSort(orderBy) }),
[query, states, orderBy],
);
const applyFilter = useCallback(
(next: RuleListFilter): void => {
void setQuery(next.query || null);
void setStates(next.states.length ? next.states : null);
setOrderBy(toOrderBy(next.sort, next.order));
setPage(1);
},
[setQuery, setStates, setOrderBy, setPage],
);
const submitQuery = useCallback(
(next: string): void => {
void setQuery(next || null);
setPage(1);
},
[setQuery, setPage],
);
const changeStates = useCallback(
(next: string[]): void => {
void setStates(next.length ? next : null);
setPage(1);
},
[setStates, setPage],
);
return { filter, applyFilter, submitQuery, changeStates };
}

View File

@@ -0,0 +1,9 @@
import { useMemo } from 'react';
import { useAppContext } from 'providers/App/App';
import type { RuleViewCtx } from './types';
export function useRuleViewCtx(): RuleViewCtx {
const { user } = useAppContext();
return useMemo(() => ({ userEmail: user.email }), [user.email]);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListRuleViewsQueryKey,
invalidateListRuleViews,
useCreateRuleView,
useDeleteRuleView,
useListRuleViews,
useUpdateRuleView,
} from 'api/generated/services/rules';
import type {
ListRuleViews200,
RenderErrorResponseDTO,
RuletypesPostableRuleViewDTO,
RuletypesRuleViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { AxiosError } from 'axios';
import type { SavedListViewsApi } from 'components/ListViews/types';
import type { RuleListFilter, RuleView } from './types';
import { DEFAULT_RULE_LIST_ORDER, DEFAULT_RULE_LIST_SORT } from './utils';
// Schema version stamped on the view's data envelope (the backend requires it).
const VIEW_DATA_VERSION = 'v1';
const toRuleView = (dto: RuletypesRuleViewDTO): RuleView => ({
id: dto.id,
name: dto.name,
filter: {
query: dto.data.query ?? '',
states: dto.data.states ?? [],
sort: dto.data.sort ?? DEFAULT_RULE_LIST_SORT,
order: dto.data.order ?? DEFAULT_RULE_LIST_ORDER,
},
});
const toPostable = (
name: string,
filter: RuleListFilter,
): RuletypesPostableRuleViewDTO => ({
name: name.trim(),
data: {
version: VIEW_DATA_VERSION,
query: filter.query.trim(),
states: filter.states,
sort: filter.sort,
order: filter.order,
},
});
const toastApiError =
(fallback: string) =>
(error: unknown): void => {
const apiError = convertToApiError(
error as AxiosError<RenderErrorResponseDTO>,
);
toast.error(apiError?.getErrorMessage() || fallback);
};
/** Org-shared saved views for the alert rules list, backed by the rule views API. */
export function useRuleViews(): SavedListViewsApi<RuleListFilter> {
const queryClient = useQueryClient();
const { data, isLoading } = useListRuleViews();
const views = useMemo<RuleView[]>(
() => (data?.data?.views ?? []).map(toRuleView),
[data],
);
const invalidate = useCallback((): void => {
void invalidateListRuleViews(queryClient);
}, [queryClient]);
const createMutation = useCreateRuleView({
mutation: {
onSuccess: invalidate,
onError: toastApiError('Failed to save view.'),
},
});
// Update returns the full view, so patch it into the cached list instead of refetching.
const updateMutation = useUpdateRuleView({
mutation: {
onSuccess: (response): void => {
const updated = response?.data;
const key = getListRuleViewsQueryKey();
const prev = queryClient.getQueryData<ListRuleViews200>(key);
if (!updated || !prev) {
return;
}
queryClient.setQueryData<ListRuleViews200>(key, {
...prev,
data: {
...prev.data,
views: (prev.data.views ?? []).map((v) =>
v.id === updated.id ? updated : v,
),
},
});
},
onError: toastApiError('Failed to update view.'),
},
});
const deleteMutation = useDeleteRuleView({
mutation: {
onSuccess: invalidate,
onError: toastApiError('Failed to delete view.'),
},
});
const createView = useCallback(
async (name: string, filter: RuleListFilter): Promise<RuleView | null> => {
try {
const res = await createMutation.mutateAsync({
data: toPostable(name, filter),
});
return res?.data ? toRuleView(res.data) : null;
} catch {
return null;
}
},
[createMutation],
);
const updateView = useCallback(
(id: string, name: string, filter: RuleListFilter): void => {
updateMutation.mutate({
pathParams: { id },
data: toPostable(name, filter),
});
},
[updateMutation],
);
const deleteView = useCallback(
(id: string): Promise<void> =>
deleteMutation.mutateAsync({ pathParams: { id } }).then(() => undefined),
[deleteMutation],
);
return { views, isLoading, createView, updateView, deleteView };
}

View File

@@ -1,10 +1,12 @@
import logEvent from 'api/common/logEvent';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { sortByColumn } from 'components/Alerts/utils';
import {
RuletypesListOrderDTO,
RuletypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView/types';
import { dataSourceForAlertType } from 'constants/alerts';
import type { AlertRule } from './types';
import type { AlertRule, RuleListFilter } from './types';
export const ALERT_RULES_REFRESH_INTERVAL = 30_000;
@@ -24,7 +26,7 @@ const ACTION_LABELS: Record<string, string> = {
export const alertActionLogEvent = (
action: string,
record: RuletypesRuleDTO,
record: AlertRule,
): void => {
const actionValue = ACTION_LABELS[action] ?? action;
void logEvent('Alert: Action', {
@@ -35,58 +37,75 @@ export const alertActionLogEvent = (
});
};
export function getAlertSortValue(
rule: AlertRule,
columnName: string,
): string | number {
switch (columnName) {
case 'state':
return rule.state ?? '';
case 'name':
return rule.alert ?? '';
case 'severity':
return rule.labels?.severity ?? '';
case 'createdAt':
return rule.createdAt ? new Date(rule.createdAt).getTime() : 0;
case 'updatedAt':
return rule.updatedAt ? new Date(rule.updatedAt).getTime() : 0;
default:
return '';
}
}
const COLUMN_TO_SORT: Record<string, RuletypesListSortDTO> = {
state: RuletypesListSortDTO.state,
name: RuletypesListSortDTO.name,
severity: RuletypesListSortDTO.severity,
createdAt: RuletypesListSortDTO.created_at,
updatedAt: RuletypesListSortDTO.updated_at,
};
export function sortRules(
rules: AlertRule[],
export const DEFAULT_RULE_LIST_SORT = RuletypesListSortDTO.updated_at;
export const DEFAULT_RULE_LIST_ORDER = RuletypesListOrderDTO.desc;
// `rule_list_filter_invalid`: DSL syntax / key / operator; `rule_list_invalid`: other list params.
export const RULE_LIST_INVALID_ERROR_CODES: readonly string[] = [
'rule_list_filter_invalid',
'rule_list_invalid',
];
export function toListSort(
orderBy: SortState | null,
): AlertRule[] {
return sortByColumn(rules, orderBy, getAlertSortValue);
}
export function filterRulesByFilters(
rules: AlertRule[],
filters: string[],
): AlertRule[] {
if (filters.length === 0) {
return rules;
): Pick<RuleListFilter, 'sort' | 'order'> {
const sort = orderBy ? COLUMN_TO_SORT[orderBy.columnName] : undefined;
if (!orderBy || !sort) {
return { sort: DEFAULT_RULE_LIST_SORT, order: DEFAULT_RULE_LIST_ORDER };
}
const stateFilters = filters
.filter((f) => f.startsWith('state:'))
.map((f) => f.replace('state:', '').toLowerCase());
const severityFilters = filters
.filter((f) => f.startsWith('severity:'))
.map((f) => f.replace('severity:', '').toLowerCase());
return rules.filter((rule) => {
const state = rule.state?.toLowerCase() ?? '';
const severity = rule.labels?.severity?.toLowerCase() ?? '';
const matchesState =
stateFilters.length === 0 || stateFilters.includes(state);
const matchesSeverity =
severityFilters.length === 0 || severityFilters.includes(severity);
return matchesState && matchesSeverity;
});
return {
sort,
order:
orderBy.order === 'asc'
? RuletypesListOrderDTO.asc
: RuletypesListOrderDTO.desc,
};
}
// The backend default sort maps to `null` so the table shows no sort indicator.
export function toOrderBy(
sort: RuletypesListSortDTO,
order: RuletypesListOrderDTO,
): SortState | null {
if (sort === DEFAULT_RULE_LIST_SORT && order === DEFAULT_RULE_LIST_ORDER) {
return null;
}
const columnName = Object.keys(COLUMN_TO_SORT).find(
(key) => COLUMN_TO_SORT[key] === sort,
);
return columnName
? { columnName, order: order === RuletypesListOrderDTO.asc ? 'asc' : 'desc' }
: null;
}
export function isSameRuleListFilter(
a: RuleListFilter,
b: RuleListFilter,
): boolean {
return (
a.query.trim() === b.query.trim() &&
a.sort === b.sort &&
a.order === b.order &&
a.states.length === b.states.length &&
[...a.states].sort().join(',') === [...b.states].sort().join(',')
);
}
export const DEFAULT_RULE_LIST_FILTER: RuleListFilter = {
query: '',
states: [],
sort: DEFAULT_RULE_LIST_SORT,
order: DEFAULT_RULE_LIST_ORDER,
};
export function isDefaultRuleListFilter(filter: RuleListFilter): boolean {
return isSameRuleListFilter(filter, DEFAULT_RULE_LIST_FILTER);
}

View File

@@ -2,14 +2,18 @@ import {
RuletypesAlertStateDTO,
RuletypesAlertTypeDTO,
RuletypesEvaluationRollingDTOKind,
RuletypesListOrderDTO,
RuletypesListSortDTO,
RuletypesPanelTypeDTO,
RuletypesQueryTypeDTO,
RuletypesRuleDTOSchemaVersion,
RuletypesRuleTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
ListRulesV3200,
RuletypesRuleConditionDTO,
RuletypesRuleDTO,
RuletypesRuleViewDTO,
} from 'api/generated/services/sigNoz.schemas';
const baseCondition: RuletypesRuleConditionDTO = {
@@ -91,3 +95,80 @@ export const alertRulesPaginationFixture: RuletypesRuleDTO[] = Array.from(
createdAt: `2023-10-${String(i + 1).padStart(2, '0')}T10:00:00Z`,
}),
);
const RULES_RESERVED_KEYWORDS = [
'name',
'severity',
'alert_type',
'rule_type',
'created_by',
'updated_by',
'created_at',
'updated_at',
];
const uniqueLabelPairs = (
rules: RuletypesRuleDTO[],
): { key: string; value: string }[] => {
const seen = new Map<string, { key: string; value: string }>();
rules.forEach((r) =>
Object.entries(r.labels ?? {}).forEach(([key, value]) => {
seen.set(`${key}=${value}`, { key, value: String(value) });
}),
);
return [...seen.values()];
};
// Mirrors /api/v3/rules: filters by `states` and pages by `limit`/`offset`.
// The `query` DSL is not evaluated; tests assert on the request instead.
export const listRulesV3Response = (
rules: RuletypesRuleDTO[],
searchParams: URLSearchParams,
): ListRulesV3200 => {
const states = searchParams.getAll('states');
const limit = Number(searchParams.get('limit') ?? rules.length);
const offset = Number(searchParams.get('offset') ?? 0);
const matching = states.length
? rules.filter((r) => states.includes(r.state))
: rules;
return {
status: 'success',
data: {
rules: matching.slice(offset, offset + limit),
total: matching.length,
labels: uniqueLabelPairs(rules),
reservedKeywords: RULES_RESERVED_KEYWORDS,
},
};
};
export const ruleViewsFixture: RuletypesRuleViewDTO[] = [
{
id: 'view-1',
name: 'Critical firing',
orgId: 'org-1',
createdAt: '2023-10-15T10:00:00Z',
updatedAt: '2023-10-19T10:00:00Z',
data: {
version: 'v1',
query: "severity = 'critical'",
states: [RuletypesAlertStateDTO.firing],
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.asc,
},
},
{
id: 'view-2',
name: 'Infra team',
orgId: 'org-1',
createdAt: '2023-10-15T10:00:00Z',
updatedAt: '2023-10-18T10:00:00Z',
data: {
version: 'v1',
query: "team = 'infra'",
states: [],
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
},
},
];

View File

@@ -6,7 +6,11 @@ import {
notificationChannelsV2,
notificationChannelV2,
} from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import {
alertRulesFixture,
listRulesV3Response,
ruleViewsFixture,
} from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
import {
@@ -228,6 +232,47 @@ export const handlers = [
ctx.json({ data: alertRulesFixture, status: 'success' }),
),
),
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json(listRulesV3Response(alertRulesFixture, req.url.searchParams)),
),
),
rest.get('http://localhost/api/v2/rule_views', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: { views: ruleViewsFixture }, status: 'success' }),
),
),
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
const body = (await req.json()) as Pick<
(typeof ruleViewsFixture)[number],
'name' | 'data'
>;
return res(
ctx.status(201),
ctx.json({
data: { ...ruleViewsFixture[0], ...body, id: 'new-view-id' },
status: 'success',
}),
);
}),
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
const body = (await req.json()) as Pick<
(typeof ruleViewsFixture)[number],
'name' | 'data'
>;
return res(
ctx.status(200),
ctx.json({
data: { ...ruleViewsFixture[0], ...body, id: req.params.id },
status: 'success',
}),
);
}),
rest.delete('http://localhost/api/v2/rule_views/:id', (_, res, ctx) =>
res(ctx.status(204)),
),
rest.post('http://localhost/api/v2/rules', async (req, res, ctx) => {
const body = (await req.json()) as { alert?: string };
return res(

View File

@@ -6,9 +6,24 @@
import { rest, type ResponseComposition, type RestContext } from 'msw';
import { screen, userEvent, within } from 'storybook/test';
import { choiceControl, countControl } from '@/storybook/controls/controls';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { useAlertRuleViewsStore } from 'container/ListAlertRules/useAlertRuleViewsStore';
import { DEFAULT_RULE_LIST_FILTER } from 'container/ListAlertRules/utils';
import {
DEFAULT_RULE_VIEW,
RULE_BUILTIN_VIEWS,
type RuleViewId,
} from 'container/ListAlertRules/list.config';
import {
orgUsersResponse,
STORY_USER_EMAIL,
} from '../../../DashboardsListPage/stories/__story_mockdata__/orgUsers';
import {
alertRulesResponse,
RULE_MAX,
@@ -18,13 +33,60 @@ import {
type SeverityChoice,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
import {
alertRulesPage,
INVALID_QUERY_CHOICES,
invalidRuleQueryError,
type InvalidQueryChoice,
RULE_VIEW_MAX,
ruleView,
ruleViewsResponse,
} from './__story_mockdata__/ruleViews';
const LIST = 'Alert rules · list';
const ACTIONS = 'Alert rules · row actions';
const VIEWS = 'Alert rules · saved views';
const ACTION_STATES = ['success', 'loading', 'error'] as const;
type ActionState = (typeof ACTION_STATES)[number];
const VIEW_CHOICES = [
'all',
'mine',
'firing',
'disabled',
'saved',
] as const satisfies readonly (RuleViewId | 'saved')[];
type ViewChoice = (typeof VIEW_CHOICES)[number];
/**
* The rail applies a view by writing `view` with its `query` and `states`, so a
* story that opens on one seeds all three or the rail shows unsaved changes.
*/
const listRoute = (view: ViewChoice): string => {
const params = new URLSearchParams({ tab: AlertListTabs.ALERT_RULES });
if (view === DEFAULT_RULE_VIEW) {
return `/alerts?${params.toString()}`;
}
const saved = ruleView(0);
const filter =
view === 'saved'
? { query: saved.data.query ?? '', states: saved.data.states ?? [] }
: RULE_BUILTIN_VIEWS.find((v) => v.id === view)?.filter(
DEFAULT_RULE_LIST_FILTER,
{ userEmail: STORY_USER_EMAIL },
);
params.set('view', view === 'saved' ? saved.id : view);
if (filter?.query) {
params.set('query', filter.query);
}
filter?.states.forEach((state) => params.append('states', state));
return `/alerts?${params.toString()}`;
};
const actionDescription = (call: string): string =>
`How the ${call} behind the row action answers. \`loading\` never answers and keeps the promise toast pending, \`error\` settles it with the failure.`;
@@ -70,6 +132,44 @@ export const alertRulesMocks = defineStoryMocks({
options: RULE_STATE_CHOICES,
value: 'mixed',
}),
invalidQuery: choiceControl<InvalidQueryChoice>('Invalid query', {
group: LIST,
description:
'The 400 code any request carrying a `query` answers with. Both show the server message under the search box.',
options: INVALID_QUERY_CHOICES,
value: 'off',
}),
view: choiceControl<ViewChoice>('Active view', {
group: VIEWS,
description:
'The rail entry the page opens on. `saved` is the first saved view; the built-ins apply a query or a state set.',
options: VIEW_CHOICES,
value: DEFAULT_RULE_VIEW,
}),
railCollapsed: toggleControl('Rail collapsed', {
group: VIEWS,
description:
'The rail collapsed from the status bar, as the browser remembers it.',
value: false,
}),
ruleViews: countControl('Saved views', {
group: VIEWS,
value: 3,
max: RULE_VIEW_MAX,
}),
ruleViewsState: choiceControl<ActionState>('Views list', {
group: VIEWS,
description:
'How the views list behind the rail answers, independent of the rule list.',
options: ACTION_STATES,
value: 'success',
}),
saveViewState: choiceControl<ActionState>('Save, rename or delete', {
group: VIEWS,
description: `${actionDescription('POST, PUT or DELETE')} The list does not change after a write: it answers with the Saved views count.`,
options: ACTION_STATES,
value: 'success',
}),
toggleState: choiceControl<ActionState>('Enable or disable', {
group: ACTIONS,
description: actionDescription('PATCH'),
@@ -90,16 +190,51 @@ export const alertRulesMocks = defineStoryMocks({
}),
},
handlers: (values, response) => [
rest.get('http://localhost/api/v3/rules', (req, res, ctx) =>
values.invalidQuery !== 'off' && req.url.searchParams.get('query')
? res(ctx.status(400), ctx.json(invalidRuleQueryError(values.invalidQuery)))
: response.json(() =>
alertRulesPage(
values.rules,
{ severity: values.ruleSeverity, state: values.ruleState },
req.url.searchParams,
),
)(req, res, ctx),
),
rest.get(
'http://localhost/api/v2/rules',
response.json(() =>
alertRulesResponse(values.rules, {
severity: values.ruleSeverity,
state: values.ruleState,
}),
'http://localhost/api/v2/users',
response.json(() => orgUsersResponse()),
),
rest.get('http://localhost/api/v2/rule_views', (_req, res, ctx) =>
actionResponse(values.ruleViewsState, res, ctx, () =>
ruleViewsResponse(values.ruleViews),
),
),
rest.post('http://localhost/api/v2/rule_views', async (req, res, ctx) => {
const body = (await req.json()) as object;
return actionResponse(values.saveViewState, res, ctx, () => ({
status: 'success',
data: { ...ruleView(values.ruleViews), ...body },
}));
}),
rest.put('http://localhost/api/v2/rule_views/:id', async (req, res, ctx) => {
const body = (await req.json()) as object;
return actionResponse(values.saveViewState, res, ctx, () => ({
status: 'success',
data: { ...ruleView(0), ...body, id: req.params.id },
}));
}),
rest.delete('http://localhost/api/v2/rule_views/:id', (_req, res, ctx) =>
values.saveViewState === 'success'
? res(ctx.status(204))
: actionResponse(values.saveViewState, res, ctx, () => null),
),
rest.patch('http://localhost/api/v2/rules/:id', (_req, res, ctx) =>
actionResponse(values.toggleState, res, ctx, () => ({
status: 'success',
@@ -124,7 +259,10 @@ export const alertRulesMocks = defineStoryMocks({
})),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.ALERT_RULES}` }),
config: (values) => ({ route: listRoute(values.view) }),
effect: (values) => {
useAlertRuleViewsStore.setState({ railCollapsed: values.railCollapsed });
},
});
/** The page fetches before it renders a row, which outlasts the 1s default. */

View File

@@ -1,14 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { alertRulesMocks } from './AlertRules.stories.mocks';
import {
alertRulesMocks,
runRowAction,
untilLoaded,
} from './AlertRules.stories.mocks';
cloneAlert,
copyLabel,
deleteAlert,
openColumnPicker,
openDeleteViewConfirm,
openDisabledRuleActions,
openQuerySuggestions,
openRenameViewPopover,
openRowActions,
openSaveViewPopover,
toggleAlert,
} from './__story_mockdata__/alertRules.play';
import AlertList from '../../index';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
@@ -18,8 +26,10 @@ type AlertRulesArgs = PageStoryArgs<typeof alertRulesMocks>;
const pageStory = storyMocks(alertRulesMocks, { layout: 'app' });
/**
* The rule list tab: every rule with its severity, state and channels. Creating
* and editing follow the legacy editor role.
* The rule list tab: the views rail beside every rule with its severity, state
* and channels, filtered server-side by a DSL query, a state set and the table
* sort, which org-shared saved views capture and restore. Creating and editing
* follow the legacy editor role.
*
* Route: `/alerts?tab=AlertRules`.
*/
@@ -46,13 +56,84 @@ export const NoRules: Story = {
args: { rules: 0 },
};
/** Search: an unmatched query retains the filters and renders the no-results branch. */
export const SearchNoResults: Story = {
/** A state filter no rule is in: the filters stay and the no-results branch renders. */
export const NoMatchingRules: Story = {
parameters: {
signoz: { route: '/alerts?tab=AlertRules&search=no-matching-alert-rule' },
signoz: { route: '/alerts?tab=AlertRules&states=recovering' },
},
};
/** The query the list was asked for does not parse: the server's message sits under the search box. */
export const InvalidQuery: Story = {
args: { invalidQuery: 'rule_list_filter_invalid' },
parameters: {
allowConsoleErrors: true,
signoz: {
route: "/alerts?tab=AlertRules&query=name%20CONTAINS%20'prod'%20AND",
},
},
};
/**
* The query box's suggestions, opened: the rule fields first, then every
* `labels.<key>` the org's rules carry.
*/
export const QuerySuggestions: Story = {
play: openQuerySuggestions,
};
/** A workspace with no saved view yet, where the rail says how to make one. */
export const NoSavedViews: Story = {
args: { ruleViews: 0 },
};
/** A shared link carrying only the view id: the view's query and states are applied once the views load. */
export const ViewApplied: Story = {
parameters: {
signoz: { route: '/alerts?tab=AlertRules&view=view-1' },
},
};
/**
* The filters changed after the view was applied: the dirty dot on the rail
* entry and the header, and Save, Save as and Reset at the foot of the rail.
*/
export const ViewModified: Story = {
parameters: {
signoz: {
route:
'/alerts?tab=AlertRules&view=view-2&query=team%20%3D%20%27infra%27&states=firing',
},
},
};
/** Filters set with no saved view selected: the rail offers to save them as a new one. */
export const UnsavedFilters: Story = {
parameters: {
signoz: { route: '/alerts?tab=AlertRules&states=firing' },
},
};
/** The rail folded away from the status bar, leaving the list the full width. */
export const RailCollapsed: Story = {
args: { railCollapsed: true },
};
/** The popover that names the current filters as a new saved view. */
export const SaveViewPopover: Story = {
play: openSaveViewPopover,
};
/** A saved view's rename popover, opened from its row. */
export const RenameViewPopover: Story = {
play: openRenameViewPopover,
};
/** Deleting a saved view asks first, because every teammate loses it too. */
export const DeleteViewConfirm: Story = {
play: openDeleteViewConfirm,
};
/** Data: the list remains mounted while its initial request is pending. */
export const Loading: Story = {
args: { dataState: 'loading' },
@@ -84,45 +165,18 @@ export const Viewer: Story = {
/** The per-rule actions: enable or disable, edit, clone and delete. */
export const RowActions: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/clone/i);
},
play: openRowActions,
};
/** Interaction: a disabled rule exposes Enable in its real row-action menu. */
export const RowActionsDisabledRule: Story = {
args: { ruleState: 'disabled' },
play: async ({ canvasElement }): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/^enable$/i);
},
play: openDisabledRuleActions,
};
/** The columns the table can show, including the audit ones it hides by default. */
export const ColumnPicker: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-columns-button',
undefined,
untilLoaded,
),
);
await screen.findByText(/toggle columns/i);
},
play: openColumnPicker,
};
/**
@@ -148,92 +202,60 @@ export const BottomStripPaginated: Story = {
/** Enable or Disable from the row menu: the promise toast, pending then settled. */
export const ToggleAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^(enable|disable)$/i);
if (args.toggleState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert (enabled|disabled)/i)).toBeVisible(),
);
}
},
play: toggleAlert,
};
/** The PATCH never answers: the "Enabling or Disabling alert..." toast stays pending. */
export const ToggleAlertLoadingToast: Story = {
args: { toggleState: 'loading' },
play: ToggleAlertToast.play,
play: toggleAlert,
};
/** The PATCH failed: the promise toast settles with the server message. */
export const ToggleAlertErrorToast: Story = {
args: { toggleState: 'error' },
parameters: { allowConsoleErrors: true },
play: ToggleAlertToast.play,
play: toggleAlert,
};
/** Clone from the row menu. */
export const CloneAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^clone$/i);
if (args.cloneState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert cloned successfully/i)).toBeVisible(),
);
}
},
play: cloneAlert,
};
/** The POST never answers: "Cloning alert..." stays pending. */
export const CloneAlertLoadingToast: Story = {
args: { cloneState: 'loading' },
play: CloneAlertToast.play,
play: cloneAlert,
};
/** The POST failed. */
export const CloneAlertErrorToast: Story = {
args: { cloneState: 'error' },
parameters: { allowConsoleErrors: true },
play: CloneAlertToast.play,
play: cloneAlert,
};
/** Delete from the row menu. */
export const DeleteAlertToast: Story = {
play: async ({ canvasElement, args }): Promise<void> => {
await runRowAction(canvasElement, /^delete$/i);
if (args.deleteState === 'success') {
await waitFor(() =>
expect(screen.getByText(/alert deleted successfully/i)).toBeVisible(),
);
}
},
play: deleteAlert,
};
/** The DELETE never answers: "Deleting alert..." stays pending. */
export const DeleteAlertLoadingToast: Story = {
args: { deleteState: 'loading' },
play: DeleteAlertToast.play,
play: deleteAlert,
};
/** The DELETE failed. */
export const DeleteAlertErrorToast: Story = {
args: { deleteState: 'error' },
parameters: { allowConsoleErrors: true },
play: DeleteAlertToast.play,
play: deleteAlert,
};
/** The copy button in a label badge's tooltip, clicked. */
export const LabelCopiedToast: Story = {
args: { tooltipsOpen: true },
play: async (): Promise<void> => {
const [copy] = await screen.findAllByRole(
'button',
{ name: 'Copy to clipboard' },
untilLoaded,
);
await userEvent.click(copy);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
play: copyLabel,
};

View File

@@ -0,0 +1,159 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { expect, screen, userEvent, within } from 'storybook/test';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
alertRulesMocks,
runRowAction,
untilLoaded,
} from '../AlertRules.stories.mocks';
interface PlayContext {
canvasElement: HTMLElement;
args: PageStoryArgs<typeof alertRulesMocks>;
}
// A row's rename and delete only take the pointer while the row is hovered,
// which a play cannot put the real cursor on.
const hoverActions = userEvent.setup({ pointerEventsCheck: 0 });
export const openQuerySuggestions = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const canvas = within(canvasElement);
// The labels come with the rule list, so the box is opened once rows are in.
await canvas.findAllByTestId('alert-actions', undefined, untilLoaded);
const search = canvas.getByTestId('alert-rules-list-search');
await userEvent.click(search.querySelector('.cm-content') ?? search);
await screen.findByText('labels.team');
};
export const openSaveViewPopover = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-rules-view-save-trigger',
undefined,
untilLoaded,
),
);
await screen.findByText('Save as view');
};
export const openRenameViewPopover = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-rename-view-1',
undefined,
untilLoaded,
),
);
await screen.findByText('Rename view');
};
export const openDeleteViewConfirm = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await hoverActions.click(
await within(canvasElement).findByTestId(
'alert-rules-view-delete-view-1',
undefined,
untilLoaded,
),
);
await screen.findByText(/shared across your organization/i);
};
export const openRowActions = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/clone/i);
};
export const openDisabledRuleActions = async ({
canvasElement,
}: PlayContext): Promise<void> => {
const [actions] = await within(canvasElement).findAllByTestId(
'alert-actions',
undefined,
untilLoaded,
);
await userEvent.click(actions);
await screen.findByText(/^enable$/i);
};
export const openColumnPicker = async ({
canvasElement,
}: PlayContext): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId(
'alert-columns-button',
undefined,
untilLoaded,
),
);
await screen.findByText(/toggle columns/i);
};
export const toggleAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await runRowAction(canvasElement, /^(enable|disable)$/i);
if (args.toggleState === 'success') {
await expect(
await screen.findByText(/alert (enabled|disabled)/i),
).toBeVisible();
}
};
export const cloneAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await runRowAction(canvasElement, /^clone$/i);
if (args.cloneState === 'success') {
await expect(
await screen.findByText(/alert cloned successfully/i),
).toBeVisible();
}
};
export const deleteAlert = async ({
canvasElement,
args,
}: PlayContext): Promise<void> => {
await runRowAction(canvasElement, /^delete$/i);
if (args.deleteState === 'success') {
await expect(
await screen.findByText(/alert deleted successfully/i),
).toBeVisible();
}
};
export const copyLabel = async (): Promise<void> => {
const [copy] = await screen.findAllByRole(
'button',
{ name: 'Copy to clipboard' },
untilLoaded,
);
await userEvent.click(copy);
await expect(await screen.findByText(/copied! use in search/i)).toBeVisible();
};

View File

@@ -0,0 +1,161 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import {
type ListRuleViews200,
type ListRulesV3200,
type RenderErrorResponseDTO,
RuletypesAlertStateDTO,
RuletypesListOrderDTO,
RuletypesListSortDTO,
type RuletypesRuleViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
alertRulesResponse,
type RuleShape,
} from '../../../stories/__story_mockdata__/alerts';
/**
* The v3 list as the API answers it: `states` and paging are applied on the
* server. The `query` DSL is not evaluated, so a query narrows nothing here.
*/
export const alertRulesPage = (
count: number,
shape: RuleShape,
searchParams: URLSearchParams,
): ListRulesV3200 => {
const states = searchParams.getAll('states');
const rules = alertRulesResponse(count, shape).data.filter(
(rule) => states.length === 0 || states.includes(rule.state),
);
const labels = new Map<string, { key: string; value: string }>();
rules.forEach((rule) =>
Object.entries(rule.labels ?? {}).forEach(([key, value]) =>
labels.set(`${key}=${value}`, { key, value }),
),
);
const limit = Number(searchParams.get('limit') ?? rules.length);
const offset = Number(searchParams.get('offset') ?? 0);
return {
status: 'success',
data: {
rules: rules.slice(offset, offset + limit),
total: rules.length,
labels: [...labels.values()],
reservedKeywords: [...RESERVED_RULE_KEYWORDS],
},
};
};
/** What `ReservedFilterKeys()` in pkg/types/ruletypes/list_filter.go answers. */
const RESERVED_RULE_KEYWORDS = [
'alert_type',
'created_at',
'created_by',
'labels.<key>',
'name',
'rule_type',
'severity',
'updated_at',
'updated_by',
] as const;
/** The two 400 codes the v3 list answers for a filter it cannot run. */
export const INVALID_QUERY_CHOICES = [
'off',
'rule_list_filter_invalid',
'rule_list_invalid',
] as const;
export type InvalidQueryChoice = (typeof INVALID_QUERY_CHOICES)[number];
const INVALID_QUERY_MESSAGES: Record<
Exclude<InvalidQueryChoice, 'off'>,
string
> = {
rule_list_filter_invalid:
"invalid filter query: unexpected token 'AND' at position 22",
rule_list_invalid: 'invalid list params: unknown sort column',
};
export const invalidRuleQueryError = (
code: Exclude<InvalidQueryChoice, 'off'>,
): RenderErrorResponseDTO =>
({
status: 'error',
error: { code, message: INVALID_QUERY_MESSAGES[code] },
}) as RenderErrorResponseDTO;
const VIEW_SEEDS: Pick<RuletypesRuleViewDTO, 'name' | 'data'>[] = [
{
name: 'Critical prod alerts',
data: {
version: 'v1',
query: "env = 'prod' AND severity = 'critical'",
states: [RuletypesAlertStateDTO.firing, RuletypesAlertStateDTO.pending],
sort: RuletypesListSortDTO.updated_at,
order: RuletypesListOrderDTO.desc,
},
},
{
name: 'Infra team',
data: {
version: 'v1',
query: "team = 'infra'",
states: [],
sort: RuletypesListSortDTO.name,
order: RuletypesListOrderDTO.asc,
},
},
{
name: 'Firing right now',
data: {
version: 'v1',
query: '',
states: [RuletypesAlertStateDTO.firing],
sort: RuletypesListSortDTO.severity,
order: RuletypesListOrderDTO.desc,
},
},
{
name: 'Recently created',
data: {
version: 'v1',
query: '',
states: [],
sort: RuletypesListSortDTO.created_at,
order: RuletypesListOrderDTO.desc,
},
},
{
name:
'Payments on-call, everything that pages the payments rotation overnight',
data: {
version: 'v1',
query: "team = 'payments' AND severity IN ('critical', 'error')",
states: [],
sort: RuletypesListSortDTO.state,
order: RuletypesListOrderDTO.asc,
},
},
];
export const RULE_VIEW_MAX = VIEW_SEEDS.length;
export const ruleView = (index: number): RuletypesRuleViewDTO => ({
id: `view-${index + 1}`,
orgId: 'org-1',
createdAt: '2026-09-01T10:00:00Z',
updatedAt: '2026-09-15T08:00:00Z',
...VIEW_SEEDS[index % RULE_VIEW_MAX],
});
export const ruleViewsResponse = (count: number): ListRuleViews200 => ({
status: 'success',
data: {
views: Array.from({ length: count }, (_unused, index) => ruleView(index)),
},
});

View File

@@ -28,6 +28,7 @@ import (
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -110,42 +111,44 @@ func TestAggrGroup(t *testing.T) {
}
)
var (
last = time.Now()
current = time.Now()
lastCurMtx = &sync.Mutex{}
alertsCh = make(chan alertmanagertypes.AlertSlice)
)
type notification struct {
alerts alertmanagertypes.AlertSlice
notifiedAt time.Time
}
alertsCh := make(chan notification)
ntfy := func(ctx context.Context, alerts ...*alertmanagertypes.Alert) bool {
// Validate that the context is properly populated.
if _, ok := notify.Now(ctx); !ok {
t.Errorf("now missing")
notifiedAt, ok := notify.Now(ctx)
assert.True(t, ok, "now missing")
_, ok = notify.GroupKey(ctx)
assert.True(t, ok, "group key missing")
lbls, ok := notify.GroupLabels(ctx)
if assert.True(t, ok, "group labels missing") {
assert.Equal(t, lset, lbls, "wrong group labels")
}
if _, ok := notify.GroupKey(ctx); !ok {
t.Errorf("group key missing")
rcv, ok := notify.ReceiverName(ctx)
if assert.True(t, ok, "receiver missing") {
assert.Equal(t, opts.Receiver, rcv, "wrong receiver")
}
if lbls, ok := notify.GroupLabels(ctx); !ok || !reflect.DeepEqual(lbls, lset) {
t.Errorf("wrong group labels: %q", lbls)
}
if rcv, ok := notify.ReceiverName(ctx); !ok || rcv != opts.Receiver {
t.Errorf("wrong receiver: %q", rcv)
}
if ri, ok := notify.RepeatInterval(ctx); !ok || ri != notificationConfig.Renotify.RenotifyInterval {
t.Errorf("wrong repeat interval: %q", ri)
ri, ok := notify.RepeatInterval(ctx)
if assert.True(t, ok, "repeat interval missing") {
assert.Equal(t, notificationConfig.Renotify.RenotifyInterval, ri, "wrong repeat interval")
}
lastCurMtx.Lock()
last = current
// Subtract a millisecond to allow for races.
current = time.Now().Add(-time.Millisecond)
lastCurMtx.Unlock()
alertsCh <- alertmanagertypes.AlertSlice(alerts)
alertsCh <- notification{
alerts: alertmanagertypes.AlertSlice(alerts),
notifiedAt: notifiedAt,
}
return true
}
assertNotifiedAfter := func(previous, current time.Time, interval time.Duration) {
t.Helper()
require.GreaterOrEqual(t, current.Sub(previous), interval, "received batch too early")
}
removeEndsAt := func(as alertmanagertypes.AlertSlice) alertmanagertypes.AlertSlice {
for i, a := range as {
ac := *a
@@ -156,29 +159,26 @@ func TestAggrGroup(t *testing.T) {
}
// Test regular situation where we wait for group_wait to send out alerts.
groupStartedAt := time.Now()
ag := newAggrGroup(context.Background(), lset, route, nil, promslog.NewNopLogger(), notificationConfig.Renotify.RenotifyInterval)
go ag.run(ntfy)
ag.insert(a1)
var lastNotificationAt time.Time
select {
case <-time.After(2 * opts.GroupWait):
t.Fatalf("expected initial batch after group_wait")
require.FailNow(t, "expected initial batch after group_wait")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupWait {
t.Fatalf("received batch too early after %v", s)
}
case notification := <-alertsCh:
assertNotifiedAfter(groupStartedAt, notification.notifiedAt, opts.GroupWait)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1})
sort.Sort(batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
require.Equal(t, exp, batch)
}
for i := 0; i < 3; i++ {
@@ -187,21 +187,16 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
t.Fatalf("expected new batch after group interval but received none")
require.FailNow(t, "expected new batch after group interval but received none")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a3})
sort.Sort(batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
require.Equal(t, exp, batch)
}
}
@@ -220,15 +215,15 @@ func TestAggrGroup(t *testing.T) {
// a2 lies way in the past so the initial group_wait should be skipped.
select {
case <-time.After(opts.GroupWait / 2):
t.Fatalf("expected immediate alert but received none")
require.FailNow(t, "expected immediate alert but received none")
case batch := <-alertsCh:
case notification := <-alertsCh:
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2})
sort.Sort(batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
require.Equal(t, exp, batch)
}
for i := 0; i < 3; i++ {
@@ -237,21 +232,16 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
t.Fatalf("expected new batch after group interval but received none")
require.FailNow(t, "expected new batch after group interval but received none")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2, a3})
sort.Sort(batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
require.Equal(t, exp, batch)
}
}
@@ -263,19 +253,14 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
t.Fatalf("expected new batch after group interval but received none")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
require.FailNow(t, "expected new batch after group interval but received none")
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
lastNotificationAt = notification.notifiedAt
batch := notification.alerts
sort.Sort(batch)
if !reflect.DeepEqual(batch, exp) {
t.Fatalf("expected alerts %v but got %v", exp, batch)
}
require.Equal(t, exp, batch)
}
// Resolve all remaining alerts, they should be removed after the next batch was sent.
@@ -289,24 +274,16 @@ func TestAggrGroup(t *testing.T) {
select {
case <-time.After(2 * opts.GroupInterval):
t.Fatalf("expected new batch after group interval but received none")
require.FailNow(t, "expected new batch after group interval but received none")
case batch := <-alertsCh:
lastCurMtx.Lock()
s := time.Since(last)
lastCurMtx.Unlock()
if s < opts.GroupInterval {
t.Fatalf("received batch too early after %v", s)
}
case notification := <-alertsCh:
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
batch := notification.alerts
sort.Sort(batch)
if !reflect.DeepEqual(batch, resolved) {
t.Fatalf("expected alerts %v but got %v", resolved, batch)
}
require.Equal(t, resolved, batch)
if !ag.empty() {
t.Fatalf("Expected aggregation group to be empty after resolving alerts: %v", ag)
}
require.Eventually(t, ag.empty, 2*opts.GroupInterval, 10*time.Millisecond, "expected aggregation group to be empty after resolving alerts: %v", ag)
}
ag.stop()

View File

@@ -199,7 +199,8 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
for _, value := range values {
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
// fixed gen_ai keys that many traces lack, so a not-found warning means nothing here
expr, _, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
if err != nil {
return nil, err
}

View File

@@ -170,6 +170,13 @@ func (q *querier) QueryRangePreview(
q.runPreviewTasks(ctx, previewTasks, results)
// a filter and a column on the same unknown key report the same warning;
// QueryRange dedupes before it builds its response too
for name, ps := range results {
ps.Warnings = orEmpty(dedupeWarnings(ps.Warnings))
results[name] = ps
}
return &qbtypes.QueryRangePreviewResponse{
CompositeQuery: results,
}, nil

View File

@@ -5,17 +5,22 @@ import (
"fmt"
"log/slog"
"strings"
"unicode"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/antlr4-go/antlr/v4"
"github.com/huandu/go-sqlbuilder"
)
const argumentPlaceholderPrefix = "__signoz_argument_"
type aggExprRewriter struct {
logger *slog.Logger
fullTextColumn *telemetrytypes.TelemetryFieldKey
@@ -55,27 +60,28 @@ func (r *aggExprRewriter) Rewrite(
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, []any, error) {
) (string, []any, []string, error) {
wrapped := fmt.Sprintf("SELECT %s", expr)
arguments := map[string]string{}
wrapped := fmt.Sprintf("SELECT %s", extractArguments(expr, arguments))
p := chparser.NewParser(wrapped)
stmts, err := p.ParseStmts()
if err != nil {
return "", nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse aggregation expression %q", expr)
return "", nil, nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
}
if len(stmts) == 0 {
return "", nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no statements found for %q", expr)
}
sel, ok := stmts[0].(*chparser.SelectQuery)
if !ok {
return "", nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "expected SelectQuery, got %T", stmts[0])
}
if len(sel.SelectItems) == 0 {
return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
return "", nil, nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
}
visitor := &exprVisitor{
@@ -85,16 +91,17 @@ func (r *aggExprRewriter) Rewrite(
fieldKeys: keys,
fullTextColumn: r.fullTextColumn,
storage: r.storage,
arguments: arguments,
}
// Rewrite the first select item (our expression)
if err := sel.SelectItems[0].Accept(visitor); err != nil {
return "", nil, err
return "", nil, nil, err
}
if visitor.isRate {
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, nil
return fmt.Sprintf("%s/%d", chparser.Format(sel.SelectItems[0]), rateInterval), visitor.chArgs, visitor.warnings, nil
}
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, nil
return chparser.Format(sel.SelectItems[0]), visitor.chArgs, visitor.warnings, nil
}
// RewriteMulti rewrites a slice of expressions.
@@ -106,24 +113,26 @@ func (r *aggExprRewriter) RewriteMulti(
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) ([]string, [][]any, error) {
) ([]string, [][]any, []string, error) {
out := make([]string, len(exprs))
var errs []error
var chArgsList [][]any
var warnings []string
for i, e := range exprs {
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
w, chArgs, exprWarnings, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e
} else {
out[i] = w
chArgsList = append(chArgsList, chArgs)
warnings = append(warnings, exprWarnings...)
}
}
if len(errs) > 0 {
return out, nil, errors.Join(errs...)
return out, nil, nil, errors.Join(errs...)
}
return out, chArgsList, nil
return out, chArgsList, warnings, nil
}
// exprVisitor walks FunctionExpr nodes and resolves and renders their
@@ -136,8 +145,10 @@ type exprVisitor struct {
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
fullTextColumn *telemetrytypes.TelemetryFieldKey
storage qbtypes.Storage
arguments map[string]string
Modified bool
chArgs []any
warnings []string
isRate bool
}
@@ -155,10 +166,16 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
args = fn.Params.Items.Items
}
// count(*) is count()
if len(args) == 1 && aggFunc.MinArgs == 0 && v.arguments[chparser.Format(args[0])] == "*" {
fn.Params.Items.Items = nil
args = nil
}
// if we know aggregation function, we must ensure that the number of arguments is correct
if aggFunc.RequireArgs {
if len(args) < aggFunc.MinArgs || len(args) > aggFunc.MaxArgs {
return errors.NewInternalf(errors.CodeInternal, "invalid number of arguments for %q: %d", name, len(args))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid number of arguments for %q: %d", name, len(args))
}
}
fn.Name.Name = aggFunc.FuncName
@@ -174,7 +191,10 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
// Handle *If functions with predicate + values
if aggFunc.FuncCombinator {
// Map the predicate (last argument)
origPred := chparser.Format(args[len(args)-1])
origPred, err := v.argumentText(name, args[len(args)-1])
if err != nil {
return err
}
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
@@ -189,6 +209,11 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
if err != nil {
return err
}
// The search scan budget applies only to the filter.
if whereClause.RequiresCostGuard {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "function `search` is not supported inside an aggregation").WithSuggestions("use `search` in the filter")
}
v.warnings = append(v.warnings, whereClause.Warnings...)
// not possible for whereClause to be empty here but still adding a check.
if whereClause.IsEmpty() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid predicate argument for %q: %q", name, origPred)
@@ -202,16 +227,23 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
}
args[len(args)-1] = parsedPred
v.Modified = true
v.chArgs = chArgs
v.chArgs = append(v.chArgs, chArgs...)
// Map each value column argument
for i := 0; i < len(args)-1; i++ {
origVal := chparser.Format(args[i])
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
origVal, err := v.argumentText(name, args[i])
if err != nil {
return err
}
fieldKey, err := fieldKeyFromArgument(name, origVal)
if err != nil {
return err
}
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
}
v.warnings = append(v.warnings, warnings...)
newVal := sqlbuilder.Escape(expr)
parsedVal, err := parseFragment(newVal)
if err != nil {
@@ -223,12 +255,19 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
} else {
// Non-If functions: map every argument as a column/value
for i, arg := range args {
orig := chparser.Format(arg)
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
expr, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
orig, err := v.argumentText(name, arg)
if err != nil {
return err
}
fieldKey, err := fieldKeyFromArgument(name, orig)
if err != nil {
return err
}
expr, warnings, err := ResolveColumn(v.ctx, v.query, v.storage, &fieldKey, dataType, v.fieldKeys)
if err != nil {
return err
}
v.warnings = append(v.warnings, warnings...)
newCol := sqlbuilder.Escape(expr)
parsed, err := parseFragment(newCol)
if err != nil {
@@ -245,6 +284,14 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
return nil
}
func (v *exprVisitor) argumentText(name string, arg chparser.Expr) (string, error) {
text, ok := v.arguments[chparser.Format(arg)]
if !ok {
return "", errors.NewInternalf(errors.CodeInternal, "argument of %q was not extracted before parsing", name)
}
return text, nil
}
// parseFragment parses a SQL expression fragment by wrapping in SELECT.
func parseFragment(sql string) (chparser.Expr, error) {
wrapped := fmt.Sprintf("SELECT %s", sql)
@@ -262,3 +309,134 @@ func parseFragment(sql string) (chparser.Expr, error) {
}
return sel.SelectItems[0].Expr, nil
}
// extractArguments replaces every argument of an aggregation call with a
// placeholder kept in arguments, because the ClickHouse parser cannot read
// the predicates such as EXISTS or CONTAINS, and field keys
// with hyphens or slashes. Unbalanced input is returned unchanged for the
// parser to reject.
func extractArguments(expr string, arguments map[string]string) string {
var out strings.Builder
written := 0
for i := 0; i < len(expr); i++ {
c := expr[i]
if c == '\'' || c == '"' || c == '`' {
end := skipQuoted(expr, i)
if end < 0 {
return expr
}
i = end
continue
}
if !isIdentifierStart(c) || (i > 0 && (isIdentifierPart(expr[i-1]) || expr[i-1] == '.')) {
continue
}
nameEnd := i
for nameEnd < len(expr) && isIdentifierPart(expr[nameEnd]) {
nameEnd++
}
open := nameEnd
for open < len(expr) && strings.ContainsRune(" \t\r\n", rune(expr[open])) {
open++
}
if _, ok := AggreFuncMap[valuer.NewString(strings.ToLower(expr[i:nameEnd]))]; !ok || open == len(expr) || expr[open] != '(' {
i = nameEnd - 1
continue
}
commas, closing := scanArguments(expr, open)
if closing < 0 {
return expr
}
out.WriteString(expr[written : open+1])
bounds := append(append([]int{open}, commas...), closing)
for j := 1; j < len(bounds); j++ {
if j > 1 {
out.WriteString(", ")
}
text := strings.TrimSpace(expr[bounds[j-1]+1 : bounds[j]])
// DISTINCT modifies the whole argument list, so it stays for the parser.
if end := strings.IndexFunc(text, unicode.IsSpace); j == 1 && end > 0 && strings.EqualFold(text[:end], "DISTINCT") {
out.WriteString("DISTINCT ")
text = strings.TrimSpace(text[end:])
}
if text == "" {
continue
}
placeholder := fmt.Sprintf("%s%d", argumentPlaceholderPrefix, len(arguments))
arguments[placeholder] = text
out.WriteString(placeholder)
}
written = closing
i = closing
}
out.WriteString(expr[written:])
return out.String()
}
// scanArguments returns the top-level commas and the closing parenthesis of
// the argument list that opens at open, with -1 as the closing index when the
// list is unterminated.
func scanArguments(expr string, open int) ([]int, int) {
var commas []int
depth := 0
for i := open + 1; i < len(expr); i++ {
switch expr[i] {
case '\'', '"', '`':
end := skipQuoted(expr, i)
if end < 0 {
return nil, -1
}
i = end
case '(', '[':
depth++
case ']':
depth--
case ')':
if depth == 0 {
return commas, i
}
depth--
case ',':
if depth == 0 {
commas = append(commas, i)
}
}
}
return nil, -1
}
// fieldKeyFromArgument reads a value argument as a single key of the filter
// grammar.
func fieldKeyFromArgument(name string, text string) (telemetrytypes.TelemetryFieldKey, error) {
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(text))
lexer.RemoveErrorListeners()
if lexer.NextToken().GetTokenType() != grammar.FilterQueryLexerKEY || lexer.NextToken().GetTokenType() != antlr.TokenEOF {
return telemetrytypes.TelemetryFieldKey{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid argument for %q: %q is not a field name", name, text)
}
return telemetrytypes.GetFieldKeyFromKeyText(text), nil
}
// skipQuoted returns the index of the quote closing the one at start, honoring
// backslash escapes and doubled quotes, or -1 when it is unterminated.
func skipQuoted(expr string, start int) int {
quote := expr[start]
for i := start + 1; i < len(expr); i++ {
switch {
case expr[i] == '\\':
i++
case expr[i] == quote && i+1 < len(expr) && expr[i+1] == quote:
i++
case expr[i] == quote:
return i
}
}
return -1
}
func isIdentifierStart(c byte) bool {
return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
func isIdentifierPart(c byte) bool {
return isIdentifierStart(c) || (c >= '0' && c <= '9')
}

View File

@@ -111,7 +111,8 @@ func Resolve(
}
// ResolveColumn resolves one key for a select field, group by, order by, or
// aggregation, and renders its column expression.
// aggregation, and renders its column expression. The warnings are the ones
// a filter on the same key reports.
func ResolveColumn(
ctx context.Context,
q qbtypes.QueryInfo,
@@ -119,12 +120,16 @@ func ResolveColumn(
key *telemetrytypes.TelemetryFieldKey,
target telemetrytypes.FieldDataType,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
) (string, []string, error) {
resolved, err := Resolve(ctx, q, storage, key, qbtypes.FilterOperatorUnknown, nil, fieldKeys)
if err != nil {
return "", err
return "", nil, err
}
return Column(ctx, q, storage, resolved, target)
column, err := Column(ctx, q, storage, resolved, target)
if err != nil {
return "", nil, err
}
return column, resolved.Warnings, nil
}
// intrinsicColumnFirst puts a column every row has first for a bare key.

View File

@@ -0,0 +1,86 @@
package querybuilder_test
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// A key metadata does not report warns and the query runs, so in a select
// field, group by, order by, or aggregation every storage's fallback must read.
// The only failure allowed is invalid input for a context the signal does not
// serve.
func TestResolveColumn_UnknownKey(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
start := uint64(releaseTime.Add(24 * time.Hour).UnixNano())
end := start + uint64(time.Hour.Nanoseconds())
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
testCases := []struct {
name string
storage qbtypes.Storage
query qbtypes.QueryInfo
}{
{
name: "Logs",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
{
name: "Logs_JSONBody",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end, BodyJSONOn: true},
},
{
name: "Traces",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end},
},
{
name: "Traces_JSONAttributes",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end, TraceAttrsJSONOn: true},
},
{
name: "Metrics",
storage: metricstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalMetrics, StartNs: start, EndNs: end},
},
{
name: "Audit",
storage: audittelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
}
for _, testCase := range testCases {
for _, value := range (telemetrytypes.FieldContext{}).Enum() {
fieldContext, ok := value.(telemetrytypes.FieldContext)
require.True(t, ok)
t.Run(testCase.name+"_"+fieldContext.StringValue(), func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "unknown.key", FieldContext: fieldContext, Signal: testCase.query.Signal}
_, _, err := querybuilder.ResolveColumn(context.Background(), testCase.query, testCase.storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
if err != nil {
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "%v", err)
}
})
}
}
}

View File

@@ -229,8 +229,9 @@ func (b *auditQueryStatementBuilder) buildListQuery(
) (*qbtypes.Statement, error) {
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -263,10 +264,11 @@ func (b *auditQueryStatementBuilder) buildListQuery(
continue
}
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &query.SelectFields[index], telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(colExpr), query.SelectFields[index].Name))
}
}
@@ -279,10 +281,11 @@ func (b *auditQueryStatementBuilder) buildListQuery(
}
for _, orderBy := range query.Order {
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
}
@@ -304,7 +307,7 @@ func (b *auditQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -321,8 +324,9 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -342,10 +346,11 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
for _, gb := range query.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
sb.SelectMore(colExpr)
@@ -354,10 +359,11 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -443,7 +449,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -461,8 +467,9 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables); err != nil {
@@ -478,10 +485,11 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
for _, gb := range query.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
colExpr := sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name)))
sb.SelectMore(colExpr)
@@ -492,10 +500,11 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -550,7 +559,7 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -337,8 +337,9 @@ func (b *logQueryStatementBuilder) buildListQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalLogs, nil, start, end)
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -377,10 +378,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
// get column expression for the field - use array index directly to avoid pointer to loop variable
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&query.SelectFields[index]), telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", colExpr, selectColumnAlias(index, query.SelectFields[index].Name))))
}
}
@@ -396,10 +398,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
colExpr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
colExpr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&orderBy.Key.TelemetryFieldKey), telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(colExpr), orderBy.Direction.StringValue()))
}
@@ -422,7 +425,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}
@@ -441,8 +444,9 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -466,10 +470,11 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
}
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
@@ -479,7 +484,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
@@ -487,6 +492,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -589,7 +595,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}
@@ -610,8 +616,9 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -631,10 +638,11 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if !info.BodyJSONOn && (strings.Contains(gb.Name, telemetrytypes.ArraySep) || strings.Contains(gb.Name, telemetrytypes.ArrayAnyIndex)) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", gb.Name)
}
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, columnKey(&gb.TelemetryFieldKey), telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
@@ -648,7 +656,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
rateInterval,
keys,
@@ -656,6 +664,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -721,7 +730,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}

View File

@@ -148,7 +148,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
))
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -235,7 +236,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -311,7 +313,8 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
))
info := querybuilder.NewQueryInfo(ctx, orgID, nil, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: query.Aggregations[0].MetricName}, start, end)
for i, g := range query.GroupBy {
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
// the meter path returns no warnings, filter warnings included
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}

View File

@@ -320,7 +320,8 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, metricstelemetryschema.TimeseriesV4ReducedLocalTableName))
sb.Select("fingerprint")
for i, g := range query.GroupBy {
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
// buildTimeSeriesCTE resolves the same keys and reports their warnings
col, _, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, err
}
@@ -544,11 +545,16 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, tsTable))
sb.Select("fingerprint")
var columnWarnings []string
for i, g := range query.GroupBy {
col, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
col, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &g.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return "", nil, nil, err
}
// the histogram rewrite adds le itself, so its warning is not the caller's
if query.Aggregations[0].Type != metrictypes.HistogramType || !isHistogramBucket(g) {
columnWarnings = append(columnWarnings, keyWarnings...)
}
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", col, GroupByColumnAlias(i, g.Name))))
}
@@ -577,7 +583,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
// the caller joins this into another builder, which compiles it again
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, preparedWhereClause.Warnings, nil
return fmt.Sprintf("(%s) AS filtered_time_series", sqlbuilder.Escape(q)), args, append(preparedWhereClause.Warnings, columnWarnings...), nil
}
func (b *StatementBuilder) buildTemporalAggregationCTE(

View File

@@ -512,6 +512,36 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "Gauge_GroupByUnknownLe_Warns",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "system.memory.usage",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationMax,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "le",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `le`, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT * FROM __spatial_aggregation_cte ORDER BY `le`, ts",
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
Warnings: []string{"key `le` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
},
expectedErr: nil,
},
{
name: "test_histogram_percentile2",
requestType: qbtypes.RequestTypeTimeSeries,
@@ -571,7 +601,8 @@ func TestStatementBuilder(t *testing.T) {
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
// the filter and the group by each report the key; the response dedupes them
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos.", "key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
},
expectedErr: nil,
},

View File

@@ -36,7 +36,8 @@ func (r *columnResolver) ValueFor(ctx context.Context, q qbtypes.QueryInfo, key
if cands := r.keys[key.Name]; len(cands) > 0 {
key = cands[0]
}
expr, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
// aggregate renders return no warnings
expr, _, err := querybuilder.ResolveColumn(ctx, q, r.storage, key, dt, r.keys)
if err != nil {
return "", err
}

View File

@@ -478,19 +478,21 @@ func groupBySelectors(groupBy []qbtypes.GroupByKey) []*telemetrytypes.FieldKeySe
// resolveGroupColumns resolves group-by keys through the storage for selection
// inside the per-trace scan; keys must cover the group-by selectors.
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, error) {
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, q qbtypes.QueryInfo, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, []string, error) {
if len(groupBy) == 0 {
return nil, nil
return nil, nil, nil
}
out := make([]groupColumn, 0, len(groupBy))
var warnings []string
for i := range groupBy {
expr, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, q, b.storage, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
return nil, nil, err
}
warnings = append(warnings, keyWarnings...)
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
}
return out, nil
return out, warnings, nil
}
// ---------------------------------------------------------------------------
@@ -598,7 +600,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
}
}
groupCols, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
groupCols, groupWarnings, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
if err != nil {
return nil, err
}
@@ -734,7 +736,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
return &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: msc.warnings,
Warnings: append(msc.warnings, groupWarnings...),
WarningsDocURL: msc.warnURL,
}, nil
}

View File

@@ -362,8 +362,9 @@ func (b *traceQueryStatementBuilder) buildListQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -382,10 +383,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, field := range query.SelectFields {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
}
@@ -406,10 +408,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
}
@@ -432,7 +435,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -569,8 +572,9 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -596,10 +600,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, gb := range query.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
@@ -608,7 +613,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
@@ -616,6 +621,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, i))
}
@@ -714,7 +720,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}
@@ -735,8 +741,9 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
) (*qbtypes.Statement, error) {
var (
cteFragments []string
cteArgs [][]any
cteFragments []string
cteArgs [][]any
columnWarnings []string
)
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, orgID, sb, query, start, end, variables)
@@ -760,10 +767,11 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
fieldNames := make([]string, 0, len(query.GroupBy))
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
for i, gb := range query.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
fieldAlias := groupByColumnAlias(i, gb.Name)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, fieldAlias)))
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
@@ -776,7 +784,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
if len(query.Aggregations) > 0 {
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
rateInterval,
keys,
@@ -784,6 +792,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
sb.SelectMore(fmt.Sprintf("%s AS __result_%d", rewritten, idx))
}
@@ -849,7 +858,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, columnWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -756,8 +756,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_non-existent.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(mapContains(attributes_string, 'non-existent.key'), attributes_string['non-existent.key'], mapContains(attributes_number, 'non-existent.key'), toString(attributes_number['non-existent.key']), mapContains(attributes_bool, 'non-existent.key'), toString(attributes_bool['non-existent.key']), NULL) AS `__SELECT_KEY_7_non-existent.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("non-existent.key")},
},
expectedErr: nil,
},
@@ -926,8 +927,9 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.version")},
},
},
{
@@ -1017,8 +1019,9 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("scope.name")},
},
},
}

View File

@@ -462,6 +462,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
// Select core fields. These are always present so the trace operator
@@ -490,12 +491,13 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
if selectedFields[field.Name] {
continue
}
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
b.stmtBuilder.logger.WarnContext(ctx, "failed to map select field",
slog.String("field", field.Name), errors.Attr(err))
continue
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", expr, selectColumnAlias(i, field.Name))))
selectedFields[field.Name] = true
}
@@ -511,10 +513,11 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
// Add order by support
orderApplied := false
for _, orderBy := range b.operator.Order {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)
if err != nil {
return nil, err
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.OrderBy(fmt.Sprintf("%s %s", sqlbuilder.Escape(expr), orderBy.Direction.StringValue()))
orderApplied = true
}
@@ -535,8 +538,9 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return &qbtypes.Statement{
Query: sql,
Args: args,
Query: sql,
Args: args,
Warnings: columnWarnings,
}, nil
}
@@ -622,6 +626,7 @@ func (b *traceOperatorCTEBuilder) getKeySelectors() []*telemetrytypes.FieldKeySe
}
func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
sb.Select(fmt.Sprintf(
@@ -631,7 +636,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -640,12 +645,13 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -662,6 +668,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -700,8 +707,9 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Query: sql,
Args: args,
Warnings: columnWarnings,
}, nil
}
@@ -722,13 +730,14 @@ func (b *traceOperatorCTEBuilder) buildTraceSummaryCTE(selectFromCTE string) {
}
func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
b.buildTraceSummaryCTE(selectFromCTE)
sb := sqlbuilder.NewSelectBuilder()
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -737,6 +746,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
@@ -744,7 +754,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -761,6 +771,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -849,17 +860,19 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Query: sql,
Args: args,
Warnings: columnWarnings,
}, nil
}
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
var columnWarnings []string
sb := sqlbuilder.NewSelectBuilder()
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
expr, keyWarnings, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, errors.NewInvalidInputf(
errors.CodeInvalidInput,
@@ -868,12 +881,13 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
err,
)
}
columnWarnings = append(columnWarnings, keyWarnings...)
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("toString(%s) AS %s", expr, clickhousesql.Identifier(gb.Name))))
}
var allAggChArgs []any
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
rewritten, chArgs, aggWarnings, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
@@ -890,6 +904,7 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
err,
)
}
columnWarnings = append(columnWarnings, aggWarnings...)
allAggChArgs = append(allAggChArgs, chArgs...)
alias := fmt.Sprintf("__result_%d", i)
@@ -939,8 +954,9 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, combinedArgs...)
return &qbtypes.Statement{
Query: sql,
Args: args,
Query: sql,
Args: args,
Warnings: columnWarnings,
}, nil
}

View File

@@ -505,7 +505,7 @@ func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetr
var keys []*telemetrytypes.TelemetryFieldKey
switch key.FieldContext {
case telemetrytypes.FieldContextBody:
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)}
keys = []*telemetrytypes.TelemetryFieldKey{bodyPath(key.Name, key.FieldDataType, value)}
case telemetrytypes.FieldContextUnspecified:
if column, ok := logsV2Columns[key.Name]; ok {
keys = []*telemetrytypes.TelemetryFieldKey{{Name: key.Name, FieldContext: telemetrytypes.FieldContextLog, FieldDataType: querybuilder.ColumnDataType(column)}}

View File

@@ -782,7 +782,7 @@ func TestIntrinsicColumnFirst(t *testing.T) {
t.Run("declared scope path selects without a literal spelling", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope}
expr, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
expr, _, err := querybuilder.ResolveColumn(ctx, q, storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
require.NoError(t, err)
assert.Equal(t, "multiIf(scope.version::String <> '', scope.version::String, NULL)", expr)
})

View File

@@ -228,7 +228,7 @@ func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
fl := familyFlagOn(t)
storage := NewStorage(nil)
expr, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
expr, _, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
require.Equal(t, "multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)", expr)
}

View File

@@ -288,14 +288,14 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
t.Run("group by string", func(t *testing.T) {
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeString, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(attributes.`user.id` IS NOT NULL, attributes.`user.id`::String, mapContains(attributes_string, 'attribute.user.id'), attributes_string['attribute.user.id'], NULL)", got)
})
t.Run("aggregation numeric", func(t *testing.T) {
key := attrKey("latency", telemetrytypes.FieldDataTypeNumber, evo)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &key, telemetrytypes.FieldDataTypeFloat64, nil)
require.NoError(t, err)
assert.Equal(t, "multiIf(toFloat64OrNull(attributes.`latency`::String) IS NOT NULL, toFloat64OrNull(attributes.`latency`::String), mapContains(attributes_number, 'attribute.latency'), toFloat64(attributes_number['attribute.latency']), NULL)", got)
})
@@ -362,7 +362,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(attributes.`http.status_code` IS NOT NULL, attributes.`http.status_code`::String, toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toString(toFloat64OrNull(attributes.`http.status_code`::String)), NULL)",
@@ -386,7 +386,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
}
ref := attrKey("http.status_code", telemetrytypes.FieldDataTypeUnspecified, nil)
got, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
got, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: attrWindowAfter[0], EndNs: attrWindowAfter[1], TraceAttrsJSONOn: true}, storage, &ref, telemetrytypes.FieldDataTypeFloat64, fieldKeys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(toFloat64OrNull(attributes.`http.status_code`::String) IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), attributes.`http.status_code` IS NOT NULL, toFloat64OrNull(attributes.`http.status_code`::String), NULL)",

View File

@@ -295,7 +295,7 @@ func TestColumnExpressionForTemporalColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage(nil)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})
@@ -326,7 +326,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &bare, telemetrytypes.FieldDataTypeFloat64, keys)
require.NoError(t, err)
assert.Equal(t, "timestamp", result)
})
@@ -337,7 +337,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &attr, telemetrytypes.FieldDataTypeFloat64, keys)
require.NoError(t, err)
assert.Contains(t, result, "attributes_number['timestamp']")
})
@@ -432,7 +432,7 @@ func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage(nil)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
result, _, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})

View File

@@ -214,8 +214,8 @@ type Resolved struct {
type AggExprRewriter interface {
// Rewrite rewrites the aggregation expression to be used in the query.
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, []string, error)
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, []string, error)
}
type Statement struct {

View File

@@ -62,10 +62,10 @@ tests/e2e/
## Writing a spec
```ts
import { expect, test } from '../../drivers/alerts';
import { expect, nameContains, test } from '../../drivers/alerts';
test('TC-01 …', async ({ alerts, alertList }) => {
await alerts.list.goto({ search: alertList.namePrefix });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
await expect(alerts.list.rows()).toHaveCount(10);
});
```

View File

@@ -102,8 +102,10 @@ export interface AlertList {
rowFor(name: string): Locator;
/** The STATE cell of `ruleId`'s row — `OK`, `Disabled`, `Firing`. */
stateCell(ruleId: string): Locator;
/** The DSL search box's editable content (a CodeMirror editor). */
search(): Locator;
searchFor(text: string): Promise<void>;
/** Replace the DSL draft and run it, as Run query does. */
runSearch(query: string): Promise<void>;
openRowActions(name: string): Promise<void>;
openFirstRowActions(): Promise<void>;
menuItem(label: string | RegExp): Locator;
@@ -115,10 +117,26 @@ export interface AlertList {
nextPageButton(): Locator;
pageSizeSelect(): Locator;
pageSizeOption(size: number): Locator;
/** A rule's name anywhere on the page, such as a clone's `<name> - Copy`. */
/** A rule's name in the list rows, such as a clone's `<name> - Copy`. */
ruleName(name: string, options?: { exact?: boolean }): Locator;
}
// ─── Saved views ─────────────────────────────────────────────────────────────
export interface AlertRuleViews {
/** A view in the rail, by id: `all`, a builtin's id or a saved view's. */
item(id: string): Locator;
/** The list heading, which names the active view. */
heading(): Locator;
saveChanges(): Locator;
/** Store the current filters on the active view. */
save(): Promise<void>;
/** Save the current filters as a new view; resolves to its id. */
saveAs(name: string): Promise<string>;
rename(id: string, name: string): Promise<void>;
remove(id: string): Promise<void>;
}
// ─── Create / edit form ──────────────────────────────────────────────────────
export interface AlertForm {
@@ -342,6 +360,7 @@ export interface Alerts {
shell: AlertsShell;
channels: AlertChannels;
list: AlertList;
views: AlertRuleViews;
form: AlertForm;
details: AlertDetails;
history: AlertHistory;
@@ -364,7 +383,8 @@ export function alertsDriver(page: Page): Alerts {
tab: (name) => page.getByRole('tab', { name }),
configSidebar: () => page.getByTestId('alert-configuration-sidebar'),
configSidebarItem: (key) => page.getByTestId(`configuration-sidebar-${key}`),
rulesHeading: () => page.getByRole('heading', { name: 'Alert Rules' }),
rulesHeading: () =>
page.getByRole('heading', { name: 'Alert Rules', exact: true }),
triggeredSearch: () => page.getByTestId('triggered-alerts-search-input'),
breadcrumb: () => page.locator('.ant-breadcrumb'),
crumb: (text) => shell.breadcrumb().getByText(text),
@@ -400,11 +420,18 @@ export function alertsDriver(page: Page): Alerts {
rows: () => page.locator('tbody tr'),
rowFor: (name) => page.locator('tbody tr', { hasText: name }).first(),
stateCell: (ruleId) => page.getByTestId(`alert-row-${ruleId}-state`),
search: () => page.getByTestId('list-alerts-search-input'),
search: () =>
page.getByTestId('alert-rules-list-search').locator('.cm-content'),
searchFor: async (text) => {
await list.search().fill(text);
await expect(page).toHaveURL(new RegExp(`search=${text}`));
runSearch: async (query) => {
await list.search().click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.insertText(query);
await expect(list.search()).toHaveText(query);
await page.getByTestId('alert-rules-list-search-submit').click();
await page.waitForURL(
(url) => (url.searchParams.get('query') ?? '') === query.trim(),
);
},
openRowActions: async (name) => {
@@ -423,7 +450,58 @@ export function alertsDriver(page: Page): Alerts {
pageSizeSelect: () => page.getByTestId('pagination-page-size'),
pageSizeOption: (size) =>
page.getByRole('option', { name: String(size), exact: true }),
ruleName: (name, options) => page.getByText(name, options),
ruleName: (name, options) => list.rows().getByText(name, options),
};
const viewWrite = (method: string): Promise<Response> =>
page.waitForResponse(
(res) =>
new URL(res.url()).pathname.startsWith('/api/v2/rule_views') &&
res.request().method() === method,
);
const views: AlertRuleViews = {
item: (id) => page.getByTestId(`alert-rules-view-${id}`),
heading: () => page.getByTestId('alert-rules-list-heading'),
saveChanges: () => page.getByTestId('alert-rules-view-save-changes'),
save: async () => {
await Promise.all([viewWrite('PUT'), views.saveChanges().click()]);
},
saveAs: async (name) => {
await page.getByTestId('alert-rules-view-save-trigger').click();
await page.getByTestId('save-alert-rule-view-name').fill(name);
const [created] = await Promise.all([
viewWrite('POST'),
page.getByTestId('save-alert-rule-view-confirm').click(),
]);
const body = (await created.json()) as { data: { id: string } };
return body.data.id;
},
// The row's actions take pointer events only while it is hovered.
rename: async (id, name) => {
await views.item(id).hover();
await page.getByTestId(`alert-rules-view-rename-${id}`).click();
await page.getByTestId('rename-alert-rule-view-name').fill(name);
await Promise.all([
viewWrite('PUT'),
page.getByTestId('rename-alert-rule-view-confirm').click(),
]);
},
remove: async (id) => {
await views.item(id).hover();
await page.getByTestId(`alert-rules-view-delete-${id}`).click();
await Promise.all([
viewWrite('DELETE'),
page
.locator('.ant-modal-confirm')
.getByRole('button', { name: 'Delete' })
.click(),
]);
},
};
const form: AlertForm = {
@@ -1046,6 +1124,7 @@ export function alertsDriver(page: Page): Alerts {
shell,
channels,
list,
views,
form,
details,
history,

View File

@@ -9,6 +9,7 @@ import {
withMetricsHistory,
withNoDataHistory,
withOwnedRules,
withOwnedViews,
withResolvedHistory,
withTracesHistory,
} from './seeds';
@@ -21,13 +22,15 @@ import {
* pays its 105 s ruler wait. One import per spec is the point.
*/
export const test = withAlerts(
withOwnedRules(
withAlertChannel(
withAlertList(
withAlertHistory(
withMetricsHistory(
withTracesHistory(
withResolvedHistory(withNoDataHistory(withEmptyHistory(kit))),
withOwnedViews(
withOwnedRules(
withAlertChannel(
withAlertList(
withAlertHistory(
withMetricsHistory(
withTracesHistory(
withResolvedHistory(withNoDataHistory(withEmptyHistory(kit))),
),
),
),
),
@@ -38,7 +41,8 @@ export const test = withAlerts(
export { expect } from '../kit';
export { alertsDriver, HISTORY_ENDPOINTS, withAlerts } from './driver';
export type { Alerts, HistoryEndpoint } from './driver';
export { nameContains } from '../dashboard-list';
export type { AlertRuleViews, Alerts, HistoryEndpoint } from './driver';
export * from './constants';
export type { TimelineItem, TimelineResponse } from './types';
export { V1_RULE_TEAM_LABEL } from './seeds';

View File

@@ -3,6 +3,7 @@ import { randomBytes } from 'crypto';
import type { Page } from '@playwright/test';
import { apiDriver } from '../api';
import type { RuletypesPostableRuleViewDTO } from '../schemas';
import { postToSeeder, type TelemetryPath } from '../telemetry';
import { SEED_B_SEVERITIES } from './constants';
@@ -51,6 +52,8 @@ export interface AlertSeeder {
rules(options: AlertRulesSeedOptions): Promise<string[]>;
deleteRule(id: string): Promise<void>;
deleteRules(ids: string[]): Promise<void>;
view(view: RuletypesPostableRuleViewDTO): Promise<string>;
deleteView(id: string): Promise<void>;
/**
* Freeze a rule's history. Rows are written on *state change* only, so the
* firing wave lands once — but once the eval window rolls past the seeded
@@ -223,6 +226,10 @@ export function alertSeeder(page: Page): AlertSeeder {
}
},
view: (view) => api.create('/api/v2/rule_views', view),
deleteView: (id) => api.tryDel(`/api/v2/rule_views/${id}`),
setDisabled: async (id, disabled) => {
await api.patch(`/api/v2/rules/${id}`, { disabled });
},

View File

@@ -1,6 +1,7 @@
import type { Browser, Page, TestType } from '@playwright/test';
import { onAdminPage, seeded, type Layer, type WorkerLayer } from '../kit';
import type { RuletypesRuleViewDataDTO } from '../schemas';
type Fixtures = Record<string, unknown>;
@@ -489,3 +490,46 @@ export const withOwnedRules: Layer<{ ownedRules: OwnedRules }> = ((
);
},
}) as never) as Layer<{ ownedRules: OwnedRules }>;
// ─── Views a single test owns ────────────────────────────────────────────────
export interface OwnedViews {
/** Seed a saved view this test owns. */
create(name: string, data: RuletypesRuleViewDataDTO): Promise<string>;
/** Track a view the *app* created, so teardown removes it too. */
register(id: string): void;
}
/** Test-scoped: views are shared across the org, so each test removes its own. */
export const withOwnedViews: Layer<{ ownedViews: OwnedViews }> = ((
test: TestType<Fixtures, Fixtures>,
) =>
test.extend({
ownedViews: async (
{ browser }: { browser: Browser },
use: (value: OwnedViews) => Promise<void>,
) => {
const ids = new Set<string>();
await use({
create: async (name, data) => {
const id = await onAdminPage(browser, (page) =>
alertsDriver(page).seed.view({ name, data }),
);
ids.add(id);
return id;
},
register: (id) => {
ids.add(id);
},
});
await onAdminPage(browser, async (page) => {
const { seed } = alertsDriver(page);
for (const id of ids) {
// eslint-disable-next-line no-await-in-loop
await seed.deleteView(id);
}
});
},
}) as never) as Layer<{ ownedViews: OwnedViews }>;

View File

@@ -64,8 +64,17 @@ export function antdDriver(page: Page): Antd {
*/
pickByName: async (select, name) => {
const before = await antd.selectedTags(select).count();
await select.click();
await expect(select).toHaveClass(/ant-select-open/);
// A re-render of the surrounding form (e.g. after a query edit) can close
// the select as the click opens it; re-click only while it is still closed.
await expect(async () => {
const isOpen = await select.evaluate((node) =>
node.classList.contains('ant-select-open'),
);
if (!isOpen) {
await select.click();
}
await expect(select).toHaveClass(/ant-select-open/, { timeout: 2_000 });
}).toPass({ timeout: 15_000 });
// `fill` rather than `keyboard.type`: every keystroke re-runs antd's
// filter, so typing a ~30 character name costs seconds per pick, while

261
tests/e2e/drivers/query-builder.ts generated Normal file
View File

@@ -0,0 +1,261 @@
/**
* 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,
} from '@playwright/test';
import { watchConsole } from './console';
import { isQueryRange } from './query-range-mock';
import { dismissQuickFiltersAnnouncement } from './logs/explorer';
import { driver, kit } from './kit';
import { EXPLORER, RELATIVE_TIME } from './query-builder/constants';
import {
addOnsAt,
exactly,
metricAt,
openSelect,
} from './query-builder/controls';
import { afterRender, editorAt } from './query-builder/editor';
import type { QueryBuilderDriver, V5Body } from './query-builder/types';
function queryBuilderDriver(page: Page): QueryBuilderDriver {
const bodies: V5Body[] = [];
page.on('request', (request: Request) => {
if (isQueryRange(request.url()) && request.method() === 'POST') {
bodies.push(request.postDataJSON() as V5Body);
}
});
const consoleWatch = watchConsole(page, {
// ANTLR reports every partial expression on console.error, prefixed
// with its position (`line 1:14 ...`).
ignore: ['line 1:', 'line 2:', 'line 3:'],
});
// Waits on a request the action started: a response to one already in
// flight (e.g. after a structural edit) would end the wait too early.
const firedBy = async (action: () => Promise<void>): Promise<V5Body[]> => {
const before = bodies.length;
const answered = page
.waitForRequest((r) => isQueryRange(r.url()) && r.method() === 'POST')
.then((request) => request.response());
await action();
await answered;
return bodies.slice(before);
};
const qb: QueryBuilderDriver = {
async openExplorer(path, { query, raw, view } = {}) {
await dismissQuickFiltersAnnouncement(page);
const params = [`relativeTime=${RELATIVE_TIME}`];
if (view) {
params.push(`panelTypes=${encodeURIComponent(JSON.stringify(view))}`);
}
const composite =
raw ??
(query === undefined
? undefined
: encodeURIComponent(JSON.stringify(query)));
if (composite !== undefined) {
params.push(`compositeQuery=${composite}`);
}
const url = `${path}?${params.join('&')}`;
// The metrics explorer runs nothing until a metric is chosen; its
// settle point is the metric list instead.
if (path === EXPLORER.metrics) {
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v2/metrics')),
page.goto(url),
]);
await expect(qb.query('A').filter.content()).toBeVisible();
return [];
}
const fired = await firedBy(async () => {
await page.goto(url);
});
await expect(qb.query('A').filter.content()).toBeVisible();
return fired;
},
query(name) {
const root = (): Locator => page.getByTestId(`query-${name}`);
// Index-suffixed testids follow screen position, not the query's
// name (A, C, B after a delete and re-add): match them in the block.
const signalSelect = (): Locator =>
root().locator('[data-testid^="query-data-source-selector-"]');
return {
root,
filter: editorAt(page, () =>
root().getByTestId('query-where-clause-editor'),
),
filterStatus: () => root().getByTestId('query-validation-status'),
aggregation: editorAt(page, () =>
root().getByTestId('query-aggregation-editor'),
),
step: () => root().getByTestId('input-Seconds'),
metric: metricAt(page, root),
addOns: addOnsAt(page, root),
actions: () => root().getByTestId('query-actions'),
menuItem: (action) =>
page.getByTestId(`query-actions-item-${action}-query`),
async clone() {
await root().getByTestId('query-actions').click();
await page.getByTestId('query-actions-item-clone-query').click();
await afterRender(page);
},
async delete() {
await root().getByTestId('query-actions').click();
await page.getByTestId('query-actions-item-delete-query').click();
await expect(root()).toHaveCount(0);
await afterRender(page);
},
async toggleVisibility() {
await root().getByTestId('qb-entity-visibility').click();
// The icon is the only visible change and carries no state.
await afterRender(page);
},
collapse: () => root().getByTestId('qb-entity-collapse').click(),
signal: () => signalSelect().locator('.ant-select-selection-item'),
async setSignal(label) {
const options = await openSelect(page, signalSelect());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.signal()).toHaveText(label);
},
};
},
tab: (queryType) => page.getByTestId(`query-type-tab-${queryType}`),
promql: () => page.getByTestId('promql-query-input').first(),
async addQuery(name) {
await page.getByTestId('add-new-query-button').click();
const block = qb.query(name);
await expect(block.filter.content()).toBeVisible();
return block;
},
queryNames: () =>
page.locator('[data-testid^="query-"]').evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('data-testid') ?? '')
.filter((id) => /^query-[A-Z]$/.test(id))
.map((id) => id.slice('query-'.length)),
),
formula(name) {
const root = (): Locator => page.getByTestId(`formula-${name}`);
return {
root,
expression: () => root().getByTestId('formula-expression'),
legend: () => root().getByTestId('formula-legend'),
async delete() {
await root().getByTestId('qb-entity-delete').click();
await expect(root()).toHaveCount(0);
// The block leaves the DOM before the editor's draft follows.
await afterRender(page);
},
};
},
async addFormula(name) {
await page.getByTestId('add-formula-button').click();
const formula = qb.formula(name);
await expect(formula.expression()).toBeVisible();
return formula;
},
traceOperator: {
root: () => page.getByTestId('trace-operator'),
// No testid; the label is followed by a "Beta" tag inside the button.
addButton: () =>
page.getByRole('button').filter({ hasText: /^Add Trace Matching/ }),
async add() {
await qb.traceOperator.addButton().click();
await expect(qb.traceOperator.editor.content()).toBeVisible();
},
async remove() {
// The only direct button child: the trash icon, no testid.
await qb.traceOperator.root().locator(':scope > button').click();
await expect(qb.traceOperator.root()).toHaveCount(0);
},
editor: editorAt(page, () => page.getByTestId('trace-operator-editor')),
},
async addToDashboard(title) {
await page.getByTestId('explorer-add-to-dashboard').click();
const dialog = page.getByTestId('export-panel-dialog');
await dialog
.getByTestId('export-dashboard-select')
.locator('input')
.fill(title);
await dialog.locator(`.ant-select-item-option[title="${title}"]`).click();
await dialog.getByTestId('export-panel-export').click();
await page.waitForURL(/\/panel\/new\?/);
await expect(qb.query('A').filter.content()).toBeVisible();
},
runButton: () => page.locator('[data-run-query-btn]').first(),
// No testid on the container.
logsResults: () => page.locator('.logs-explorer-views'),
async run() {
await afterRender(page);
return firedBy(() => qb.runButton().click());
},
async rerunUntil(read, expected) {
await expect(async () => {
await expect(qb.runButton()).toHaveText(/Run Query/);
await afterRender(page);
await qb.runButton().click();
await expect.poll(read, { timeout: 3_000 }).toEqual(expected);
}).toPass({ timeout: 25_000 });
},
async runShortcut() {
await afterRender(page);
return firedBy(() => page.keyboard.press('ControlOrMeta+Enter'));
},
quickFilter(title, value) {
const checkbox = (): Locator =>
page.getByTestId(`checkbox-${title}-${value}`);
return {
checkbox,
toggle: () => firedBy(() => checkbox().click()),
};
},
spec(fired, name) {
for (const body of fired) {
const match = body.compositeQuery?.queries?.find(
(q) => q.spec?.name === name,
);
if (match) {
return match.spec;
}
}
throw new Error(`no query ${name} in ${fired.length} query_range bodies`);
},
sent: () => bodies,
latest: (name) =>
[...bodies]
.reverse()
.flatMap((body) => body.compositeQuery?.queries ?? [])
.find((query) => query.spec?.name === name)?.spec,
urlQuery() {
const raw = new URL(page.url()).searchParams.get('compositeQuery');
if (!raw) {
return null;
}
// The app encodes before URLSearchParams encodes again.
return JSON.parse(decodeURIComponent(raw)) as Record<string, unknown>;
},
console: consoleWatch,
};
return qb;
}
/** Layers `qb` onto another host's test, e.g. the alert-rule fixtures. */
export const withQueryBuilder = driver('qb', ({ page }) =>
queryBuilderDriver(page),
);
export const test = withQueryBuilder(kit);
export { expect } from './kit';
export * from './query-builder/constants';
export * from './query-builder/types';
export { reduceToLabel } from './query-builder/controls';
export { withMetricTypes } from './query-builder/seed';

View File

@@ -0,0 +1,17 @@
/**
* 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.
*/
export const EXPLORER = {
logs: '/logs/logs-explorer',
traces: '/traces-explorer',
metrics: '/metrics-explorer/explorer',
} as const;
/** A monotonic Sum in the golden dataset. */
export const SUM_METRIC = 'signoz_calls_total';
/** Another golden Sum, for switching between metrics of one type. */
export const OTHER_SUM_METRIC = 'signoz_latency_count';
/** Inside the golden dataset's window. */
export const RELATIVE_TIME = '6h';

View File

@@ -0,0 +1,136 @@
/**
* 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 } from '@playwright/test';
import { antdDriver } from '../antd';
import { editorAt } from './editor';
import type { AddOnField, AddOns, MetricControls } from './types';
/**
* Opens the antd select inside `select` and returns its options. Scoped to the
* list the select owns: a previous select's list can still be mid-close.
*/
export async function openSelect(
page: Page,
select: Locator,
): Promise<Locator> {
await select.locator('.ant-select-selector').click();
const list = await antdDriver(page).ownDropdown(select);
await expect(list).toBeVisible();
return list.locator('.ant-select-item-option');
}
/** antd renders select options with trailing whitespace ("Sum "). */
export const exactly = (label: string): RegExp =>
new RegExp(`^\\s*${label}\\s*$`);
/** A reduce-to option or value by its operator: "Sum of values in timeframe". */
export const reduceToLabel = (operator: string): RegExp =>
new RegExp(`^\\s*${operator} of values`);
const ADD_ON_CONTENT: Record<AddOnField, string> = {
group_by: 'group-by-content',
having: 'having-content',
order_by: 'order-by-content',
limit: 'limit-content',
legend_format: 'legend-format-content',
reduce_to: 'reduce-to-content',
};
export function addOnsAt(page: Page, root: () => Locator): AddOns {
const content = (field: AddOnField): Locator =>
root().getByTestId(ADD_ON_CONTENT[field]);
const groupBy = (): Locator => root().getByTestId('group-by');
const addOns: AddOns = {
toggles: () => root().locator('[data-testid^="query-add-on-"]'),
async open(field) {
if (await content(field).isVisible()) {
return;
}
await root().getByTestId(`query-add-on-${field}`).click();
await expect(content(field)).toBeVisible();
},
content,
groupBy: {
async add(key) {
const options = await openSelect(page, groupBy());
await groupBy().locator('input').fill(key);
await options
.filter({ hasText: exactly(key) })
.first()
.click();
await page.keyboard.press('Escape');
await expect(
groupBy().locator('.ant-select-selection-item', { hasText: key }),
).toBeVisible();
},
async remove(key) {
await groupBy()
.locator('.ant-select-selection-item', { hasText: key })
.locator('.ant-select-selection-item-remove')
.click();
},
async values() {
const texts = await groupBy()
.locator('.ant-select-selection-item-content')
.allTextContents();
return texts.map((text) => text.trim());
},
},
having: editorAt(page, () => root().getByTestId('having-editor')),
limit: () => content('limit').getByTestId('input-Limit'),
legend: () => content('legend_format').getByTestId('input-Legend format'),
reduceTo: {
value: () =>
root().getByTestId('reduce-to').locator('.ant-select-selection-item'),
// Labels read "<Op> of values in timeframe"; matched on the operator.
async set(label) {
const options = await openSelect(page, root().getByTestId('reduce-to'));
await options.filter({ hasText: reduceToLabel(label) }).click();
await expect(addOns.reduceTo.value()).toHaveText(reduceToLabel(label));
},
},
};
return addOns;
}
export function metricAt(page: Page, root: () => Locator): MetricControls {
const picker = (): Locator =>
root().locator('[data-testid^="metric-name-selector-"]');
const time = (): Locator => root().getByTestId('metrics-time-aggregation');
const space = (): Locator => root().getByTestId('metrics-space-aggregation');
return {
async select(name) {
// An antd AutoComplete: the testid is on a wrapper.
const field = picker();
const options = await openSelect(page, field);
await field.locator('input').fill(name);
// Options carry the metric's type and unit next to its name.
await options.filter({ hasText: name }).first().click();
},
// The selection item, not the root: these selects render their list
// inside the root, so its text includes every option.
input: () => picker().locator('input'),
timeAggregation: () => time().locator('.ant-select-selection-item'),
spaceAggregation: () => space().locator('.ant-select-selection-item'),
async setTimeAggregation(label) {
const options = await openSelect(page, time());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.timeAggregation()).toHaveText(label);
},
async setSpaceAggregation(label) {
const options = await openSelect(page, space());
await options.filter({ hasText: exactly(label) }).click();
await expect(this.spaceAggregation()).toHaveText(label);
},
async spaceOptions() {
const options = await openSelect(page, space());
const labels = await options.allTextContents();
await page.keyboard.press('Escape');
return labels.map((label) => label.trim());
},
};
}

View File

@@ -0,0 +1,77 @@
/**
* 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 } from '@playwright/test';
import type { FilterEditor } from './types';
/** CodeMirror renders its placeholder inside the empty line. */
const EDITOR_DOC = async (root: Locator): Promise<string> => {
const lines = root.locator('.cm-content .cm-line');
const texts = await lines.allTextContents();
const placeholders = await lines.evaluateAll((nodes) =>
nodes.map((node) => node.querySelector('.cm-placeholder')?.textContent ?? ''),
);
return texts
.map((text, i) =>
placeholders[i] ? text.replace(placeholders[i], '') : text,
)
.join('\n');
};
export function editorAt(page: Page, root: () => Locator): FilterEditor {
const content = (): Locator => root().locator('.cm-content');
const editor: FilterEditor = {
root,
content,
async type(text) {
await editor.clear();
await page.keyboard.type(text);
},
async append(text) {
await content().click();
await page.keyboard.press('End');
await page.keyboard.type(text);
},
async clear() {
await content().click();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.press('Backspace');
await expect.poll(() => EDITOR_DOC(root())).toBe('');
},
press: (key) => page.keyboard.press(key),
expression: () => EDITOR_DOC(root()),
lineCount: () => root().locator('.cm-line'),
suggestions: () => page.locator('.cm-tooltip-autocomplete'),
async openSuggestions() {
await expect(async () => {
await page.keyboard.press('Control+Space');
await expect(editor.suggestions()).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
},
suggestion: (label) =>
page
.locator('.cm-tooltip-autocomplete [role="option"]')
.filter({ hasText: label }),
};
return editor;
}
/**
* Two frames: an edit reaches React state on its next scheduler task, and an
* action in the same frame reads the previous render. A long render delays
* the frame itself, so this cannot pass before the commit.
*/
export async function afterRender(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
// Runs in the browser; the e2e tsconfig has no DOM lib.
const { requestAnimationFrame: frame } = globalThis as unknown as {
requestAnimationFrame(this: void, callback: () => void): number;
};
frame(() => frame(() => resolve()));
}),
);
}

50
tests/e2e/drivers/query-builder/seed.ts generated Normal file
View File

@@ -0,0 +1,50 @@
/**
* 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 { postToSeeder } from '../telemetry';
import { seeded } from '../kit';
import type { MetricTypes } from './types';
const HISTOGRAM_BOUNDS = ['1', '2', '4', '+Inf'];
/** Ten minutes of a gauge and a cumulative histogram, names unique per worker. */
export const withMetricTypes = seeded(
'metricTypes',
async ({ page, workerIndex }) => {
const suffix = `${workerIndex}_${Date.now()}`;
const types: MetricTypes = {
gauge: `e2e_qb_gauge_${suffix}`,
histogram: `e2e_qb_histogram_${suffix}`,
};
const now = Date.now();
const points: Record<string, unknown>[] = [];
for (let minute = 0; minute < 10; minute += 1) {
const timestamp = new Date(now - (10 - minute) * 60_000).toISOString();
points.push({
metric_name: types.gauge,
labels: { host: 'qb-host' },
timestamp,
value: 10 + minute,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
});
HISTOGRAM_BOUNDS.forEach((le, bucket) => {
points.push({
metric_name: types.histogram,
labels: { le },
timestamp,
value: (minute + 1) * (bucket + 1),
type_: 'Histogram',
temporality: 'Cumulative',
is_monotonic: false,
});
});
}
await postToSeeder(page.request, '/telemetry/metrics', points);
return types;
},
);

225
tests/e2e/drivers/query-builder/types.ts generated Normal file
View File

@@ -0,0 +1,225 @@
/**
* 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 { Locator } from '@playwright/test';
import type { ConsoleWatch } from '../console';
/** The golden dataset has only Sums; these are seeded per worker. */
export interface MetricTypes {
gauge: string;
histogram: string;
}
/** One entry of a v5 `query_range` body's `compositeQuery.queries`. */
export interface V5Query {
type: string;
spec: {
name?: string;
signal?: string;
disabled?: boolean;
filter?: { expression?: string };
aggregations?: {
expression?: string;
metricName?: string;
timeAggregation?: string;
spaceAggregation?: string;
reduceTo?: string;
}[];
groupBy?: { name: string }[];
having?: { expression?: string };
order?: { key: { name: string }; direction: string }[];
limit?: number;
stepInterval?: number;
[key: string]: unknown;
};
}
export interface V5Body {
requestType: string;
compositeQuery: { queries: V5Query[] };
}
export interface FilterEditor {
root(): Locator;
/** The contenteditable CodeMirror surface. */
content(): Locator;
/** Replaces the expression by typing it key by key. */
type(text: string): Promise<void>;
/** Appends at the caret without clearing. */
append(text: string): Promise<void>;
clear(): Promise<void>;
press(key: string): Promise<void>;
/** The document text, placeholder excluded, lines joined with `\n`. */
expression(): Promise<string>;
lineCount(): Locator;
/** The open autocomplete popup. */
suggestions(): Locator;
/**
* Ctrl+Space: opens the popup at the caret. Typing opens it only once the
* debounced key fetch answers, which a loaded stack can delay past a test's
* next step.
*/
openSuggestions(): Promise<void>;
suggestion(label: string | RegExp): Locator;
}
export interface QueryBlock {
root(): Locator;
filter: FilterEditor;
/** The filter's status icon: shown only while unfocused and non-empty. */
filterStatus(): Locator;
aggregation: FilterEditor;
/** The aggregation's "every … Seconds" input. */
step(): Locator;
metric: MetricControls;
addOns: AddOns;
/** The "…" menu: offered only where several queries are allowed. */
actions(): Locator;
/** A row of the open "…" menu. */
menuItem(action: 'clone' | 'delete'): Locator;
clone(): Promise<void>;
delete(): Promise<void>;
toggleVisibility(): Promise<void>;
collapse(): Promise<void>;
/** The data-source dropdown's value; only on hosts that let it change. */
signal(): Locator;
setSignal(label: 'Logs' | 'Traces' | 'Metrics'): Promise<void>;
}
export interface FormulaBlock {
root(): Locator;
expression(): Locator;
legend(): Locator;
delete(): Promise<void>;
}
/** `query-add-on-<field>` toggle values: a documented DOM contract. */
export type AddOnField =
| 'group_by'
| 'having'
| 'order_by'
| 'limit'
| 'legend_format'
| 'reduce_to';
export interface AddOns {
/** The toggle row; its children name the add-ons on offer. */
toggles(): Locator;
/** Idempotent: leaves an already-open add-on open. */
open(field: AddOnField): Promise<void>;
content(field: AddOnField): Locator;
groupBy: {
/** Types `key` and picks the suggestion with that exact name. */
add(key: string): Promise<void>;
remove(key: string): Promise<void>;
/** The selected keys, in order. */
values(): Promise<string[]>;
};
having: FilterEditor;
limit(): Locator;
legend(): Locator;
reduceTo: {
/** The selected value. */
value(): Locator;
set(label: string): Promise<void>;
};
}
/** The metrics aggregation section: metric picker plus time/space selects. */
export interface MetricControls {
select(name: string): Promise<void>;
/** The metric name input. */
input(): Locator;
/** The selected value. Absent for histograms. */
timeAggregation(): Locator;
/** The selected value. */
spaceAggregation(): Locator;
setTimeAggregation(label: string): Promise<void>;
setSpaceAggregation(label: string): Promise<void>;
/** Labels offered by the space-aggregation select, as shown. */
spaceOptions(): Promise<string[]>;
}
export interface QuickFilterValue {
checkbox(): Locator;
/** Toggles the value; returns the query_range bodies the toggle fired. */
toggle(): Promise<V5Body[]>;
}
/** The explorer's tab, carried in the `panelTypes` param. */
export type ExplorerView = 'list' | 'graph' | 'table';
export interface OpenOptions {
/** Serialised the way the app writes it. */
query?: unknown;
/** A `compositeQuery` value sent as given, for malformed input. */
raw?: string;
view?: ExplorerView;
}
export interface QueryBuilderDriver {
/**
* Resolves once the page's own first query_range has answered: the explorer
* rewrites its URL and re-mounts the builder until then, which drops
* keystrokes. Returns the bodies that load fired.
*/
openExplorer(path: string, options?: OpenOptions): Promise<V5Body[]>;
query(name: string): QueryBlock;
/** Adds the next query (B, C, …) and waits for its block. */
addQuery(name: string): Promise<QueryBlock>;
/** Builder query names, top to bottom. */
queryNames(): Promise<string[]>;
/** Panel-editor query-type tab, by its queryType (`builder`, `promql`, …). */
tab(queryType: string): Locator;
promql(): Locator;
formula(name: string): FormulaBlock;
addFormula(name: string): Promise<FormulaBlock>;
traceOperator: {
root(): Locator;
/** "Add Trace Matching": offered on traces hosts that allow it. */
addButton(): Locator;
add(): Promise<void>;
remove(): Promise<void>;
editor: FilterEditor;
};
/**
* Explorer "Add to dashboard": picks the dashboard titled `title` and waits
* for the new-panel editor the export navigates to.
*/
addToDashboard(title: string): Promise<void>;
runButton(): Locator;
/** The logs explorer's results area, below the builder. */
logsResults(): Locator;
/** Clicks Run; returns every query_range body the click fired. */
run(): Promise<V5Body[]>;
/**
* Clicks Run until `read()` equals `expected`. For panel-editor structural
* edits, whose draft can lag the DOM: an early Run is a cache hit that
* sends nothing. Never clicks while the button is Cancel.
*/
rerunUntil(read: () => unknown, expected: unknown): Promise<void>;
/**
* Ctrl/Cmd+Enter from the focused field; returns the fired bodies. Waits two
* frames first: editor changes reach React state on its next scheduler task,
* and a keypress inside the same frame as the last keystroke reads the
* previous render — a gap no person can produce.
*/
runShortcut(): Promise<V5Body[]>;
/** A value in an explorer's checkbox quick filter, by its header title. */
quickFilter(title: string, value: string): QuickFilterValue;
/** The spec of query `name` in the first body that has it. */
spec(bodies: V5Body[], name: string): V5Query['spec'];
/** Every query_range body sent since the test started. */
sent(): V5Body[];
/**
* Query `name`'s spec in the newest body that has it, whatever sent it. The
* panel editor fetches on structural edits, so Run after one can be a cache
* hit that sends nothing: poll this rather than waiting on Run.
*/
latest(name: string): V5Query['spec'] | undefined;
/** The URL's `compositeQuery`, parsed. */
urlQuery(): Record<string, unknown> | null;
console: ConsoleWatch;
}

View File

@@ -272,7 +272,9 @@ export async function holdQueryRange(
});
await page.route(QUERY_RANGE_GLOB, async (route) => {
await gate;
await fulfill(route, body);
// A cancelled run aborts its request while it is held; there is
// nothing left to answer.
await fulfill(route, body).catch(() => undefined);
});
return { release: () => release() };
}

View File

@@ -264,8 +264,8 @@ export function routingDriver(page: Page): Routing {
alerts: {
landmark: () =>
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
.getByRole('heading', { name: 'Alert Rules', exact: true })
.or(page.getByTestId('alert-rules-list-search')),
},
settings: {

View File

@@ -4,6 +4,7 @@ import {
ALERTS_LIST_PATH,
DEFAULT_RELATIVE_TIME,
expect,
nameContains,
test,
V1_RULE_TEAM_LABEL,
} from '../../drivers/alerts';
@@ -58,7 +59,7 @@ test.describe('Alert details', () => {
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
await expect(page).toHaveURL(/[?&]ruleId=/);
await alerts.shell.goto({ search: name });
await alerts.shell.goto({ query: nameContains(name) });
await expect(alerts.list.ruleName(`${name} - Copy`)).toBeVisible();
});
@@ -83,7 +84,7 @@ test.describe('Alert details', () => {
]);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
await alerts.shell.goto({ search: name });
await alerts.shell.goto({ query: nameContains(name) });
await expect(alerts.list.ruleName(name, { exact: true })).toHaveCount(0);
});
@@ -196,7 +197,7 @@ test.describe('Alert details', () => {
).toBeVisible();
await expect(alerts.details.headerField('title')).toContainText(renamed);
await alerts.shell.goto({ search: renamed });
await alerts.shell.goto({ query: nameContains(renamed) });
await expect(alerts.list.ruleName(renamed)).toBeVisible();
});
@@ -224,7 +225,7 @@ test.describe('Alert details', () => {
alerts.details.footerSaveButton().click(),
]);
await alerts.shell.goto({ search: renamed });
await alerts.shell.goto({ query: nameContains(renamed) });
await expect(alerts.list.ruleName(renamed)).toBeVisible();
// The History tab renders the same name input with no save button, so an
@@ -233,7 +234,7 @@ test.describe('Alert details', () => {
await alerts.details.nameInput().fill(`${renamed}-unsaved`);
await expect(alerts.details.footerSaveButton()).toHaveCount(0);
await alerts.shell.goto({ search: renamed });
await alerts.shell.goto({ query: nameContains(renamed) });
await expect(alerts.list.ruleName(renamed)).toBeVisible();
await expect(alerts.list.ruleName(`${renamed}-unsaved`)).toHaveCount(0);
});

View File

@@ -1,4 +1,9 @@
import { ALERT_LIST_PAGE_SIZE, expect, test } from '../../drivers/alerts';
import {
ALERT_LIST_PAGE_SIZE,
expect,
nameContains,
test,
} from '../../drivers/alerts';
// What stays here is what needs a real backend behind a real table: the seeded
// rules rendering, the server-side filter semantics, and the write paths that
@@ -13,7 +18,7 @@ test.describe('Alert rules list', () => {
alerts,
alertList,
}) => {
await alerts.list.goto({ search: alertList.namePrefix });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
for (const header of ['Status', 'Alert Name', 'Severity', 'Labels']) {
await expect(alerts.list.columnHeader(header)).toBeVisible();
@@ -39,32 +44,20 @@ test.describe('Alert rules list', () => {
alertList,
}) => {
const perSeverity = alertList.count / 3;
const scoped = { search: alertList.namePrefix };
const query = `${nameContains(alertList.namePrefix)} AND severity = 'warning'`;
// One filter kind: a third of the batch.
await alerts.list.goto({
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning']),
});
await alerts.list.goto({ query });
await expect(alerts.list.rows()).toHaveCount(perSeverity);
// Both kinds, both satisfied — seeded rules never fire, so they sit in
// `inactive` and the intersection is unchanged.
await alerts.list.goto({
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:inactive']),
});
await alerts.list.goto({ query, states: 'inactive' });
await expect(alerts.list.rows()).toHaveCount(perSeverity);
// Both kinds, only one satisfied. Under AND this is empty; under OR the
// severity match alone would still surface all `perSeverity` rules.
await alerts.list.goto(
{
...scoped,
alertRulesFilters: JSON.stringify(['severity:warning', 'state:firing']),
},
{ expectRows: false },
);
await alerts.list.goto({ query, states: 'firing' }, { expectRows: false });
await expect(alerts.list.rows()).toHaveCount(0);
});
@@ -76,7 +69,7 @@ test.describe('Alert rules list', () => {
const name = `e2e-alert-row-${Date.now()}`;
const ruleId = await ownedRules.threshold(name);
await alerts.list.goto({ search: name });
await alerts.list.goto({ query: nameContains(name) });
await expect(alerts.list.stateCell(ruleId)).toHaveText('OK');
const patched = (): Promise<unknown> =>
@@ -110,7 +103,7 @@ test.describe('Alert rules list', () => {
await ownedRules.register(cloned);
await expect(alerts.shell.message('Alert cloned successfully')).toBeVisible();
await alerts.list.goto({ search: name });
await alerts.list.goto({ query: nameContains(name) });
await expect(alerts.list.ruleName(`${name} - Copy`)).toBeVisible();
await alerts.list.openRowActions(`${name} - Copy`);
@@ -133,7 +126,7 @@ test.describe('Alert rules list', () => {
alerts,
alertList,
}) => {
await alerts.list.goto({ search: alertList.namePrefix });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
const firstAscending = await alerts.list.rows().first().textContent();
await alerts.list.sortButton('Alert Name').click();
@@ -141,7 +134,7 @@ test.describe('Alert rules list', () => {
await alerts.list.sortButton('Alert Name').click();
await expect(alerts.list.rows().first()).not.toHaveText(firstAscending ?? '');
await alerts.list.goto({ search: alertList.namePrefix });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
await alerts.list.pageSizeSelect().click();
await alerts.list.pageSizeOption(20).click();
@@ -154,7 +147,7 @@ test.describe('Alert rules list', () => {
alerts,
alertList,
}) => {
await alerts.list.goto({ search: alertList.namePrefix });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
await alerts.list.openFirstRowActions();
await alerts.list.menuItem('Edit in New Tab').first().waitFor();

View File

@@ -0,0 +1,117 @@
import { expect, nameContains, test } from '../../drivers/alerts';
// The rail's own behaviour (dirty states, builtin views, link precedence) is
// asserted against mocks in
// `frontend/src/container/ListAlertRules/__tests__/ListAlertRules.views.test.tsx`.
// What stays here is what a real `/api/v2/rule_views` has to persist.
const viewName = (label: string): string =>
`e2e-alert-view-${label}-w${test.info().workerIndex}-${Date.now()}`;
test.describe('Alert rules saved views', () => {
test('TC-01 the current search saves as a view that reapplies from a bare link', async ({
authedPage: page,
alerts,
alertList,
ownedViews,
}) => {
const query = `${nameContains(alertList.namePrefix)} AND severity = 'warning'`;
const name = viewName('save');
await alerts.list.goto({ query });
const id = await alerts.views.saveAs(name);
ownedViews.register(id);
await expect(page).toHaveURL(new RegExp(`[?&]view=${id}`));
await expect(alerts.views.heading()).toHaveText(name);
await alerts.list.goto({ view: id });
await expect(alerts.views.heading()).toHaveText(name);
await expect(alerts.list.rows()).toHaveCount(alertList.count / 3);
});
test('TC-02 selecting a saved view applies its filter', async ({
authedPage: page,
alerts,
alertList,
ownedViews,
}) => {
const query = `${nameContains(alertList.namePrefix)} AND severity = 'critical'`;
const name = viewName('apply');
const id = await ownedViews.create(name, { version: 'v1', query });
await alerts.list.goto({ query: nameContains(alertList.namePrefix) });
await alerts.views.item(id).click();
await expect(page).toHaveURL(new RegExp(`[?&]view=${id}`));
await expect(alerts.views.heading()).toHaveText(name);
await expect(alerts.list.rows()).toHaveCount(alertList.count / 3);
});
test('TC-03 Save changes stores the edited filter on the view', async ({
alerts,
alertList,
ownedViews,
}) => {
const scope = nameContains(alertList.namePrefix);
const id = await ownedViews.create(viewName('update'), {
version: 'v1',
query: `${scope} AND severity = 'warning'`,
});
await alerts.list.goto({ view: id });
const edited = `${scope} AND severity = 'critical'`;
await alerts.list.runSearch(edited);
await alerts.views.save();
await expect(alerts.views.saveChanges()).toHaveCount(0);
await alerts.list.goto({ view: id });
await expect(alerts.list.search()).toHaveText(edited);
});
test('TC-04 rename and delete survive a reload', async ({
authedPage: page,
alerts,
alertList,
ownedViews,
}) => {
const name = viewName('rename');
const id = await ownedViews.create(name, {
version: 'v1',
query: nameContains(alertList.namePrefix),
});
await alerts.list.goto({ view: id });
await alerts.views.rename(id, `${name}-renamed`);
await page.reload();
await expect(alerts.views.item(id)).toHaveText(`${name}-renamed`);
await alerts.views.remove(id);
await page.reload();
await expect(alerts.views.item('all')).toBeVisible();
await expect(alerts.views.item(id)).toHaveCount(0);
});
test('TC-05 a shared link keeps its own query over the view and marks it modified', async ({
alerts,
alertList,
ownedViews,
}) => {
const scope = nameContains(alertList.namePrefix);
const name = viewName('link');
const id = await ownedViews.create(name, {
version: 'v1',
query: `${scope} AND severity = 'warning'`,
});
await alerts.list.goto({ view: id, query: scope });
await expect(alerts.views.heading()).toHaveText(name);
await expect(alerts.views.saveChanges()).toBeVisible();
await expect(alerts.list.table.totalCount()).toContainText(
`of ${alertList.count}`,
);
});
});

View File

@@ -0,0 +1,154 @@
import {
expect,
EXPLORER,
SUM_METRIC,
test,
} from '../../drivers/query-builder';
const HAVING = ['count() > 10', 'count() IN [1, 2]', 'count() NOT IN [1, 2]'];
test.describe('Query builder — add-ons', () => {
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-21 ${explorer} group by keeps order through removal and reload`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer], { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.groupBy.add('deployment.environment');
await addOns.groupBy.add('k8s.namespace.name');
await addOns.groupBy.remove('service.name');
const fired = await qb.run();
const expected = ['deployment.environment', 'k8s.namespace.name'];
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual(
expected,
);
const reloaded = await qb.openExplorer(EXPLORER[explorer], {
view: 'graph',
query: qb.urlQuery(),
});
expect(qb.spec(reloaded, 'A').groupBy?.map((key) => key.name)).toEqual(
expected,
);
expect(await qb.query('A').addOns.groupBy.values()).toEqual(expected);
});
}
test('TC-21 metrics group by offers the metric’s own attributes', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
const query = qb.query('A');
await query.metric.select(SUM_METRIC);
await query.addOns.groupBy.add('operation');
const fired = await qb.run();
expect(qb.spec(fired, 'A').groupBy?.map((key) => key.name)).toEqual([
'operation',
]);
});
for (const having of HAVING) {
test(`TC-22 having "${having}" is sent verbatim and accepted`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('having');
await addOns.having.type(having);
await addOns.having.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').having?.expression).toBe(having);
expect(qb.console.failedResponses).toEqual([]);
});
}
test('TC-23 the logs list orders by timestamp then id, traces by timestamp only', async ({
qb,
}) => {
const logs = await qb.openExplorer(EXPLORER.logs, { view: 'list' });
expect(qb.spec(logs, 'A').order?.map((o) => o.key.name)).toEqual([
'timestamp',
'id',
]);
const traces = await qb.openExplorer(EXPLORER.traces, { view: 'list' });
expect(qb.spec(traces, 'A').order?.map((o) => o.key.name)).toEqual([
'timestamp',
]);
});
test('TC-24 a positive limit is sent; empty and zero send none', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('limit');
await addOns.limit().fill('10');
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
await addOns.limit().fill('');
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
await addOns.limit().fill('3');
await qb.run();
await addOns.limit().fill('0');
expect(qb.spec(await qb.run(), 'A')).not.toHaveProperty('limit');
});
test('TC-24 a limit input drops the sign of a negative number', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.open('limit');
await addOns.limit().fill('10');
expect(qb.spec(await qb.run(), 'A').limit).toBe(10);
await addOns.limit().fill('-5');
await expect(addOns.limit()).toHaveValue('5');
expect(qb.spec(await qb.run(), 'A').limit).toBe(5);
expect(qb.console.failedResponses).toEqual([]);
});
test('TC-25 the legend prefills from group by and a custom one is kept', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
const addOns = qb.query('A').addOns;
await addOns.open('group_by');
await addOns.groupBy.add('service.name');
await addOns.groupBy.add('deployment.environment');
await addOns.open('legend_format');
await expect(addOns.legend()).toHaveValue(
'service.name = {{service.name}}, deployment.environment = {{deployment.environment}}',
);
await addOns.legend().fill('{{service.name}} logs');
await addOns.groupBy.add('k8s.namespace.name');
await expect(addOns.legend()).toHaveValue('{{service.name}} logs');
expect(qb.spec(await qb.run(), 'A').legend).toBe('{{service.name}} logs');
});
test('TC-25 the legend opens empty without a group by', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await qb.query('A').addOns.open('legend_format');
await expect(qb.query('A').addOns.legend()).toHaveValue('');
});
});

View File

@@ -0,0 +1,80 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// One comma-separated term per entry in the request's `aggregations`.
const EXPRESSIONS: Record<string, string[]> = {
'a count': ['count()'],
'a distinct count': ['count_distinct(service.name)'],
'a percentile of a span field': ['p99(duration_nano)'],
'two aggregations': ['count()', 'p99(duration_nano)'],
};
test.describe('Query builder — logs and traces aggregations', () => {
for (const [label, terms] of Object.entries(EXPRESSIONS)) {
test(`TC-17 ${label} reaches the request`, async ({ qb }) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
const aggregation = qb.query('A').aggregation;
await aggregation.type(terms.join(', '));
await aggregation.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.map((a) => a.expression)).toEqual(
terms,
);
});
}
test('TC-18 the step interval reaches the request', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
await qb.query('A').step().fill('120');
await page.keyboard.press('Tab');
const fired = await qb.run();
expect(qb.spec(fired, 'A').stepInterval).toBe(120);
});
test('TC-30 a trace operator replaces per-query aggregation until removed', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.traces, { view: 'graph' });
await (await qb.addQuery('B')).filter.type("http.status_code = '500'");
await qb.query('B').filter.press('Escape');
await qb.traceOperator.add();
await qb.traceOperator.editor.type('A => B');
await qb.traceOperator.editor.press('Escape');
for (const name of ['A', 'B']) {
await expect(qb.query(name).aggregation.root()).toHaveCount(0);
await expect(qb.query(name).addOns.toggles()).toHaveCount(0);
}
const fired = await qb.run();
const operator = fired
.flatMap((body) => body.compositeQuery.queries)
.find((query) => query.type === 'builder_trace_operator');
expect(operator?.spec).toMatchObject({ expression: 'A => B' });
await qb.traceOperator.remove();
await expect(qb.query('A').aggregation.root()).toBeVisible();
await qb.rerunUntil(
() =>
qb
.sent()
.at(-1)
?.compositeQuery.queries.some(
(query) => query.type === 'builder_trace_operator',
),
false,
);
});
test('TC-30 the logs explorer offers no trace matching', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await expect(qb.query('A').root()).toBeVisible();
await expect(qb.traceOperator.addButton()).toHaveCount(0);
});
});

View File

@@ -0,0 +1,76 @@
import { AlertType, expect, test as alertsTest } from '../../drivers/alerts';
import { withQueryBuilder } from '../../drivers/query-builder';
const test = withQueryBuilder(alertsTest);
const FILTER_A = "http.method = 'GET'";
const FILTER_B = "http.status_code = '500'";
const TRACE_MATCH = 'A => B';
interface SavedRule {
condition?: {
compositeQuery?: {
queries?: {
type: string;
spec: {
name?: string;
expression?: string;
filter?: { expression?: string };
};
}[];
};
};
}
test.describe('Query builder — alerts', () => {
test('TC-06 a trace-matching traces alert saves and reopens intact', async ({
authedPage: page,
alerts,
alertChannel,
ownedRules,
qb,
}) => {
await alerts.form.gotoV2({ alertType: AlertType.TRACES });
await alerts.form.name().fill(`e2e-qb-tc06-${Date.now()}`);
await qb.query('A').filter.type(FILTER_A);
await qb.query('A').filter.press('Escape');
const b = await qb.addQuery('B');
await b.filter.type(FILTER_B);
await b.filter.press('Escape');
await qb.traceOperator.add();
await qb.traceOperator.editor.type(TRACE_MATCH);
await qb.traceOperator.editor.press('Escape');
// Blur first: the editor's blur re-renders the condition section, which
// otherwise closes the channel select as its click opens it.
await qb.traceOperator.editor.content().blur();
await alerts.form.selectThresholdChannel(0, alertChannel.name);
const [response] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
),
alerts.form.saveButton().click(),
]);
await ownedRules.register(response);
const saved = response.request().postDataJSON() as SavedRule;
const queries = saved.condition?.compositeQuery?.queries ?? [];
expect(queries).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'builder_trace_operator',
spec: expect.objectContaining({ expression: TRACE_MATCH }),
}),
]),
);
const { data } = (await response.json()) as { data: { id: string } };
await alerts.details.gotoOverview(String(data.id));
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER_A);
await expect.poll(() => qb.query('B').filter.expression()).toBe(FILTER_B);
await expect
.poll(() => qb.traceOperator.editor.expression())
.toBe(TRACE_MATCH);
});
});

View File

@@ -0,0 +1,72 @@
import { emptyDashboard, test as editorTest } from '../../drivers/panel-editor';
import {
expect,
EXPLORER,
withQueryBuilder,
} from '../../drivers/query-builder';
const test = withQueryBuilder(editorTest);
const FILTER = "severity_text = 'ERROR'";
const AGGREGATION = 'count_distinct(service.name)';
interface StoredQuery {
spec: {
filter?: { expression?: string };
aggregations?: { expression?: string }[];
};
}
/** Where a non-List panel keeps its builder queries in the v2 dashboard spec. */
interface StoredPanel {
spec: {
queries: {
spec: { plugin: { kind: string; spec: { queries: StoredQuery[] } } };
}[];
};
}
test.describe('Query builder — explorer to dashboard', () => {
test('TC-05 a logs explorer query survives add to dashboard, save and reopen', async ({
dashboards,
editor,
qb,
}) => {
const dashboard = emptyDashboard();
const title = dashboard.spec.display.name;
const id = await dashboards.seed(dashboard);
await qb.openExplorer(EXPLORER.logs, { view: 'graph' });
await qb.query('A').filter.type(FILTER);
await qb.query('A').filter.press('Escape');
await qb.query('A').aggregation.type(AGGREGATION);
await qb.query('A').aggregation.press('Escape');
await qb.run();
await qb.addToDashboard(title);
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
await expect
.poll(() => qb.query('A').aggregation.expression())
.toBe(AGGREGATION);
await editor.save();
// The shared spec types model only bare BuilderQuery plugins.
const panels = (await dashboards.get(id)).spec.panels as unknown as Record<
string,
StoredPanel
>;
const [panelId] = Object.keys(panels);
const plugin = panels[panelId].spec.queries[0].spec.plugin;
expect(plugin.kind).toBe('signoz/CompositeQuery');
expect(plugin.spec.queries[0].spec).toMatchObject({
filter: { expression: FILTER },
aggregations: [{ expression: AGGREGATION }],
});
await dashboards.openEditor(id, panelId);
await expect.poll(() => qb.query('A').filter.expression()).toBe(FILTER);
await expect
.poll(() => qb.query('A').aggregation.expression())
.toBe(AGGREGATION);
});
});

View File

@@ -0,0 +1,284 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// Typing is real keystrokes: the regressions here (caret jumps, swallowed
// Enter, the run shortcut) only show up through the keyboard path.
const RECENT_KEPT = "service.name = 'frontend' AND severity_text = 'WARN'";
const RECENT_REMOVED = 'logger.name EXISTS';
const COMPOUND: Record<string, string> = {
'NOT over a parenthesised OR':
"NOT (severity_text = 'INFO' OR severity_text = 'WARN') AND service.name = 'frontend'",
'nested groups':
"(service.name = 'frontend' AND (severity_text = 'ERROR' OR severity_text = 'WARN'))",
'NOT IN with EXISTS':
"service.name NOT IN ['frontend', 'adservice'] AND logger.name EXISTS",
};
const queryWithFilter = (expression: string): unknown => ({
queryType: 'builder',
builder: {
queryFormulas: [],
queryData: [
{
queryName: 'A',
dataSource: 'logs',
expression: 'A',
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
});
const VERBATIM: Record<string, string> = {
'an escaped quote': "logger.name = 'it\\'s'",
'a double-quoted string': 'logger.name = "double quoted"',
'a quoted numeric string': "logger.name = '123'",
'a bare number': 'logger.name = 123',
'a boolean': 'logger.name = true',
'spaces and a backslash': "logger.name = 'a b\\\\c'",
};
test.describe('Query builder — filter editor', () => {
test('TC-07 key, operator and value suggestions build the expression', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('severity_te');
await filter.openSuggestions();
await filter.suggestion('severity_text').first().click();
await expect.poll(() => filter.expression()).toMatch(/^severity_text\s*$/);
await filter.openSuggestions();
await expect(filter.suggestion(/^=/).first()).toBeVisible();
await filter.append('= ');
await filter.openSuggestions();
await filter.suggestion('ERROR').first().click();
await expect
.poll(() => filter.expression())
.toMatch(/^severity_text = 'ERROR'\s*$/);
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression?.trim()).toBe(
"severity_text = 'ERROR'",
);
});
for (const [label, expression] of Object.entries(VERBATIM)) {
test(`TC-08 ${label} is sent and restored verbatim`, async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type(expression);
await filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
await page.reload();
await expect(filter.content()).toBeVisible();
await expect.poll(() => filter.expression()).toBe(expression);
await expect(qb.query('A').filterStatus()).toHaveCount(0);
});
}
test('TC-11 the status icon shows only for an unfocused invalid filter', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const query = qb.query('A');
await query.filter.type('service.name =');
await expect(query.filterStatus()).toHaveCount(0);
await query.filter.content().blur();
await expect(query.filterStatus()).toBeVisible();
await query.filter.type("service.name = 'frontend'");
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
await query.filter.clear();
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
});
test('TC-12 Enter and Shift+Enter never insert a newline or run', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type("severity_text = 'ERROR'");
await filter.press('Escape');
await expect(filter.suggestions()).toHaveCount(0);
const sentBefore = qb.sent().length;
await filter.press('Enter');
await filter.press('Shift+Enter');
await expect(filter.lineCount()).toHaveCount(1);
await expect.poll(() => filter.expression()).toBe("severity_text = 'ERROR'");
expect(qb.sent()).toHaveLength(sentBefore);
});
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-13 Cmd/Ctrl+Enter in the ${explorer} filter runs the edited query`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer]);
const filter = qb.query('A').filter;
await filter.type("service.name = 'frontend'");
const fired = await qb.runShortcut();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name = 'frontend'",
);
await expect(filter.lineCount()).toHaveCount(1);
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
});
}
test('TC-14 Escape closes suggestions and keeps the text', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('serv');
await filter.openSuggestions();
await filter.press('Escape');
await expect(filter.suggestions()).toHaveCount(0);
await expect.poll(() => filter.expression()).toBe('serv');
await filter.press('i');
await expect.poll(() => filter.expression()).toBe('servi');
await filter.press('Escape');
await filter.press('Escape');
await expect.poll(() => filter.expression()).toBe('servi');
await expect(filter.content()).toBeFocused();
});
test('TC-09 value suggestions fill an IN list', async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
await filter.type('service.name IN ');
await filter.openSuggestions();
await filter.suggestion('frontend').first().click();
await expect
.poll(() => filter.expression())
.toBe("service.name IN ['frontend'] ");
await filter.type("service.name IN ['frontend', 'cartservice']");
await filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name IN ['frontend', 'cartservice']",
);
await expect(qb.query('A').filterStatus()).toHaveCount(0);
});
for (const [label, expression] of Object.entries(COMPOUND)) {
test(`TC-10 ${label} is valid and sent verbatim`, async ({ qb }) => {
await qb.openExplorer(EXPLORER.logs);
const query = qb.query('A');
await query.filter.type(expression);
await query.filter.press('Escape');
await query.filter.content().blur();
await expect(query.filterStatus()).toHaveCount(0);
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expression);
});
}
test('TC-15 an expression with CRLF from the URL loads without a RangeError', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, {
query: queryWithFilter("body CONTAINS 'line1\r\nline2'"),
});
await expect.poll(() => qb.query('A').filter.expression()).toContain('line1');
expect(qb.console.errors.filter((e) => e.includes('RangeError'))).toEqual([]);
await qb.query('A').filter.append(" AND severity_text = 'ERROR'");
await expect
.poll(() => qb.query('A').filter.expression())
.toMatch(/severity_text = 'ERROR'$/);
expect(qb.console.errors).toEqual([]);
});
test('TC-15 recent searches are offered, applied and deleted', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
for (const expression of [RECENT_KEPT, RECENT_REMOVED]) {
await filter.type(expression);
await filter.press('Escape');
await qb.run();
}
await filter.clear();
await filter.openSuggestions();
await expect(filter.suggestion(RECENT_KEPT)).toBeVisible();
await expect(filter.suggestion(RECENT_REMOVED)).toBeVisible();
await filter.suggestion(RECENT_KEPT).click();
await expect.poll(() => filter.expression()).toBe(RECENT_KEPT);
await filter.clear();
await filter.openSuggestions();
await filter
.suggestion(RECENT_REMOVED)
.getByRole('button', { name: 'Remove from recent searches' })
.click();
await expect(filter.suggestion(RECENT_REMOVED)).toHaveCount(0);
});
test('TC-15 recent searches survive a reload and stay with their signal', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await qb.query('A').filter.type(RECENT_KEPT);
await qb.query('A').filter.press('Escape');
await qb.run();
await qb.openExplorer(EXPLORER.logs);
await qb.query('A').filter.clear();
await qb.query('A').filter.openSuggestions();
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toBeVisible();
await qb.openExplorer(EXPLORER.traces);
await qb.query('A').filter.clear();
await qb.query('A').filter.openSuggestions();
await expect(qb.query('A').filter.suggestion(RECENT_KEPT)).toHaveCount(0);
});
test('TC-16 a quick-filter value toggles between excluded and cleared', async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const filter = qb.query('A').filter;
const handTyped = "service.name = 'frontend'";
const excluded = `${handTyped} severity_text not in ['ERROR']`;
const error = qb.quickFilter('Severity Text', 'ERROR');
await filter.type(handTyped);
await qb.run();
for (const expected of [excluded, handTyped, excluded]) {
const fired = await error.toggle();
expect(qb.spec(fired, 'A').filter?.expression).toBe(expected);
await expect.poll(() => filter.expression()).toBe(expected);
}
await expect(error.checkbox()).toHaveAttribute('aria-checked', 'false');
});
});

View File

@@ -0,0 +1,106 @@
import {
expect,
EXPLORER,
type MetricTypes,
OTHER_SUM_METRIC,
SUM_METRIC,
test as base,
withMetricTypes,
} from '../../drivers/query-builder';
const test = withMetricTypes(base);
const MOMENT_AGGREGATIONS = ['Sum', 'Avg', 'Min', 'Max'];
interface TypeDefaults {
metric: (types: MetricTypes) => string;
time?: string;
space: string;
spaceOptions: string[];
}
const DEFAULTS: Record<string, TypeDefaults> = {
sum: {
metric: () => SUM_METRIC,
time: 'Rate',
space: 'Sum',
spaceOptions: MOMENT_AGGREGATIONS,
},
gauge: {
metric: (types) => types.gauge,
time: 'Avg',
space: 'Avg',
spaceOptions: MOMENT_AGGREGATIONS,
},
histogram: {
metric: (types) => types.histogram,
space: 'P90',
spaceOptions: ['P50', 'P75', 'P90', 'P95', 'P99'],
},
};
test.describe('Query builder — metric aggregations', () => {
for (const [type, expected] of Object.entries(DEFAULTS)) {
test(`TC-19 a ${type} metric gets its type's default aggregations`, async ({
metricTypes,
qb,
}) => {
const metricName = expected.metric(metricTypes);
await qb.openExplorer(EXPLORER.metrics);
const metric = qb.query('A').metric;
await metric.select(metricName);
await expect(metric.spaceAggregation()).toHaveText(expected.space);
expect(await metric.spaceOptions()).toEqual(expected.spaceOptions);
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
metricName,
spaceAggregation: expected.space.toLowerCase(),
...(expected.time && { timeAggregation: expected.time.toLowerCase() }),
});
});
}
test('TC-19 a histogram has no time aggregation control', async ({
metricTypes,
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
await qb.query('A').metric.select(metricTypes.histogram);
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('P90');
await expect(qb.query('A').metric.timeAggregation()).toHaveCount(0);
});
test('TC-20 aggregations reset only when the metric type changes', async ({
metricTypes,
qb,
}) => {
await qb.openExplorer(EXPLORER.metrics);
const metric = qb.query('A').metric;
await metric.select(SUM_METRIC);
await metric.setTimeAggregation('Increase');
await metric.setSpaceAggregation('Max');
await metric.select(OTHER_SUM_METRIC);
await expect(metric.timeAggregation()).toHaveText('Increase');
await expect(metric.spaceAggregation()).toHaveText('Max');
await metric.select(metricTypes.gauge);
await expect(metric.timeAggregation()).toHaveText('Avg');
await expect(metric.spaceAggregation()).toHaveText('Avg');
await metric.select(SUM_METRIC);
await expect(metric.timeAggregation()).toHaveText('Rate');
await expect(metric.spaceAggregation()).toHaveText('Sum');
const fired = await qb.run();
expect(qb.spec(fired, 'A').aggregations?.[0]).toMatchObject({
metricName: SUM_METRIC,
timeAggregation: 'rate',
spaceAggregation: 'sum',
});
});
});

View File

@@ -0,0 +1,136 @@
import {
logsCountQuery,
metricsQuery,
SINGLE_PANEL_ID,
singlePanelDashboard,
test as editorTest,
} from '../../drivers/panel-editor';
import {
expect,
OTHER_SUM_METRIC,
SUM_METRIC,
withQueryBuilder,
} from '../../drivers/query-builder';
// An added query continues the previous query's signal. On these metrics
// panels it starts with no metric, and Run does nothing until one is picked.
const test = withQueryBuilder(editorTest);
const SIGNAL_LABEL = { logs: 'Logs', traces: 'Traces' } as const;
const CLONED_FILTER = "operation = '/ads/get'";
test.describe('Query builder — multiple queries', () => {
for (const signal of ['logs', 'traces'] as const) {
test(`TC-28 a query added to a ${signal} panel stays ${signal}`, async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery({ signal }) }),
SINGLE_PANEL_ID,
);
const added = await qb.addQuery('B');
await expect(added.signal()).toHaveText(SIGNAL_LABEL[signal]);
await expect(added.aggregation.root()).toBeVisible();
await expect(added.metric.input()).toHaveCount(0);
await added.filter.type("service.name = 'frontend'");
await added.filter.press('Escape');
await qb.rerunUntil(() => qb.latest('B')?.signal, signal);
});
}
test('TC-28 a deleted name is reused by the next added query', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
for (const name of ['B', 'C']) {
await (await qb.addQuery(name)).metric.select(OTHER_SUM_METRIC);
}
expect(await qb.queryNames()).toEqual(['A', 'B', 'C']);
await qb.query('B').delete();
expect(await qb.queryNames()).toEqual(['A', 'C']);
const reAdded = await qb.addQuery('B');
expect(await qb.queryNames()).toEqual(['A', 'C', 'B']);
await reAdded.metric.select(SUM_METRIC);
await expect(reAdded.metric.input()).toHaveValue(SUM_METRIC);
await expect(qb.query('C').metric.input()).toHaveValue(OTHER_SUM_METRIC);
});
test('TC-28 a clone and a hidden query reach the request', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery({ filter: CLONED_FILTER }) }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
await qb.query('A').clone();
await expect
.poll(() => qb.query('C').filter.expression())
.toBe(CLONED_FILTER);
await qb.query('B').toggleVisibility();
await qb.runButton().click();
await expect.poll(() => qb.latest('B')?.disabled).toBe(true);
expect(qb.latest('C')).toMatchObject({
filter: { expression: CLONED_FILTER },
aggregations: [expect.objectContaining({ metricName: SUM_METRIC })],
});
});
test('TC-28 the last remaining query offers no delete', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
await qb.query('B').delete();
await qb.query('A').actions().click();
await expect(qb.query('A').menuItem('clone')).toBeVisible();
await expect(qb.query('A').menuItem('delete')).toHaveCount(0);
});
test('TC-29 a formula over two queries reaches the request', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await (await qb.addQuery('B')).metric.select(OTHER_SUM_METRIC);
const formula = await qb.addFormula('F1');
await formula.expression().fill('A / B');
await formula.legend().fill('ratio');
await qb.runButton().click();
await expect
.poll(() => qb.latest('F1'))
.toMatchObject({ expression: 'A / B', legend: 'ratio' });
await formula.delete();
await qb.rerunUntil(
() =>
qb
.sent()
.at(-1)
?.compositeQuery.queries.map((q) => q.spec.name),
['A', 'B'],
);
});
});

View File

@@ -0,0 +1,203 @@
import {
logsCountQuery,
metricsQuery,
PanelKind,
SINGLE_PANEL_ID,
singlePanelDashboard,
test as editorTest,
} from '../../drivers/panel-editor';
import {
expect,
reduceToLabel,
SUM_METRIC,
withMetricTypes,
withQueryBuilder,
} from '../../drivers/query-builder';
const test = withMetricTypes(withQueryBuilder(editorTest));
const LOGS_FILTER = "severity_text = 'ERROR'";
const TRACES_FILTER = "http.method = 'GET'";
const PROMQL = 'sum(rate(signoz_calls_total[5m]))';
const QUERIES = {
logs: () => logsCountQuery(),
traces: () => logsCountQuery({ signal: 'traces' }),
metrics: () => metricsQuery(),
};
const ALL = ['group_by', 'having', 'order_by', 'limit', 'legend_format'];
const NO_HAVING = ['group_by', 'order_by', 'limit', 'legend_format'];
const METRICS = ['having', 'order_by', 'limit', 'legend_format'];
/**
* Add-on toggles each panel kind offers per signal. Metrics group by lives in
* the aggregation row, so it is never a toggle there.
*/
const ADD_ONS: Record<
keyof typeof QUERIES,
Partial<Record<string, string[]>>
> = {
logs: {
[PanelKind.TimeSeries]: ALL,
[PanelKind.BarChart]: ALL,
[PanelKind.Table]: ALL,
[PanelKind.PieChart]: ALL,
[PanelKind.Number]: ['legend_format'],
[PanelKind.Heatmap]: NO_HAVING,
},
traces: {
[PanelKind.TimeSeries]: ALL,
[PanelKind.BarChart]: ALL,
[PanelKind.Table]: ALL,
[PanelKind.PieChart]: ALL,
[PanelKind.Number]: ['legend_format'],
[PanelKind.Heatmap]: NO_HAVING,
},
metrics: {
[PanelKind.TimeSeries]: METRICS,
[PanelKind.BarChart]: METRICS,
[PanelKind.Table]: [...METRICS, 'reduce_to'],
[PanelKind.PieChart]: [...METRICS, 'reduce_to'],
[PanelKind.Number]: ['legend_format', 'reduce_to'],
[PanelKind.Heatmap]: ['order_by', 'limit', 'legend_format', 'bucket_options'],
},
};
test.describe('Query builder — panel editor', () => {
for (const [signal, kinds] of Object.entries(ADD_ONS)) {
for (const [kind, expected] of Object.entries(kinds)) {
test(`TC-27 ${signal} on ${kind.replace('signoz/', '')} offers its add-ons`, async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({
kind: kind as PanelKind,
query: QUERIES[signal as keyof typeof QUERIES](),
}),
SINGLE_PANEL_ID,
);
await expect(qb.query('A').addOns.toggles()).toHaveCount(
expected?.length ?? 0,
);
const toggles = await qb
.query('A')
.addOns.toggles()
.evaluateAll((nodes) =>
nodes.map((node) =>
(node.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
),
);
expect(toggles).toEqual(expected);
});
}
}
test('TC-27 a limit hidden by a kind switch reopens with its value', async ({
dashboards,
editor,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery() }),
SINGLE_PANEL_ID,
);
const addOns = qb.query('A').addOns;
await addOns.open('limit');
await addOns.limit().fill('7');
await editor.switchType(PanelKind.Number);
await expect(addOns.content('limit')).toHaveCount(0);
await editor.switchType(PanelKind.TimeSeries);
await expect(addOns.limit()).toHaveValue('7');
// The kind switch already fetched: Run can be a cache hit.
await qb.runButton().click();
await expect.poll(() => qb.latest('A')?.limit).toBe(7);
});
test('TC-26 reduce-to follows the metric type and keeps a choice within it', async ({
dashboards,
metricTypes,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ kind: PanelKind.Table, query: metricsQuery() }),
SINGLE_PANEL_ID,
);
const query = qb.query('A');
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
await query.addOns.reduceTo.set('Max');
await query.metric.select('signoz_latency_count');
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Max'));
await query.metric.select(metricTypes.gauge);
await expect(query.addOns.reduceTo.value()).toHaveText(
reduceToLabel('Average'),
);
await query.metric.select(SUM_METRIC);
await expect(query.addOns.reduceTo.value()).toHaveText(reduceToLabel('Sum'));
expect(qb.spec(await qb.run(), 'A').aggregations?.[0]).toMatchObject({
metricName: SUM_METRIC,
reduceTo: 'sum',
});
});
test('TC-31 switching signal restores the query left on each signal', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: logsCountQuery({ groupBy: [] }) }),
SINGLE_PANEL_ID,
);
const query = qb.query('A');
await query.filter.type(LOGS_FILTER);
await query.filter.press('Escape');
await query.setSignal('Traces');
await expect.poll(() => query.filter.expression()).toBe('');
await query.filter.type(TRACES_FILTER);
await query.filter.press('Escape');
await query.setSignal('Logs');
await expect.poll(() => query.filter.expression()).toBe(LOGS_FILTER);
await query.setSignal('Traces');
await expect.poll(() => query.filter.expression()).toBe(TRACES_FILTER);
await qb.runButton().click();
await expect
.poll(() => qb.latest('A'))
.toMatchObject({
signal: 'traces',
filter: { expression: TRACES_FILTER },
});
});
test('TC-33 a PromQL query is sent as promql and kept across tab switches', async ({
dashboards,
qb,
}) => {
await dashboards.seedAndEdit(
singlePanelDashboard({ query: metricsQuery() }),
SINGLE_PANEL_ID,
);
await qb.tab('promql').click();
await qb.promql().fill(PROMQL);
const fired = await qb.run();
expect(fired.at(-1)?.compositeQuery.queries[0]).toMatchObject({
type: 'promql',
spec: { name: 'A', query: PROMQL },
});
await qb.tab('clickhouse_sql').click();
await qb.tab('builder').click();
await expect(qb.query('A').metric.spaceAggregation()).toHaveText('Sum');
await qb.tab('promql').click();
await expect(qb.promql()).toHaveValue(PROMQL);
});
});

View File

@@ -0,0 +1,80 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
import {
holdQueryRange,
mockQueryRange,
mockQueryRangeError,
QueryRange,
unmockQueryRange,
} from '../../drivers/query-range-mock';
test.describe('Query builder — run and cancel', () => {
test('TC-34 a held run can be cancelled and the next run goes through', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
const held = await holdQueryRange(page, QueryRange.empty());
await qb.query('A').filter.type("severity_text = 'WARN'");
await qb.query('A').filter.press('Escape');
await qb.runButton().click();
await expect(qb.runButton()).toHaveText('Cancel');
await qb.runButton().click();
await expect(qb.runButton()).toHaveText(/Run Query/);
await expect(qb.logsResults()).toContainText('Query cancelled');
held.release();
await unmockQueryRange(page);
await qb.query('A').filter.type("severity_text = 'ERROR'");
await qb.query('A').filter.press('Escape');
// The cancelled query is re-sent alongside; the newest request wins.
await qb.run();
expect(qb.latest('A')?.filter?.expression).toBe("severity_text = 'ERROR'");
await expect(qb.logsResults()).not.toContainText('Query cancelled');
});
test('TC-35 a failed run keeps the query editable and the next run recovers', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await mockQueryRangeError(page, 'mocked query failure');
await qb.query('A').filter.type("severity_text = 'INFO'");
await qb.query('A').filter.press('Escape');
await qb.runButton().click();
// The failed request is retried before the error settles.
await expect(qb.runButton()).toHaveText(/Run Query/, { timeout: 20_000 });
await expect(qb.logsResults()).toContainText('mocked query failure');
await expect
.poll(() => qb.query('A').filter.expression())
.toBe("severity_text = 'INFO'");
await unmockQueryRange(page);
await qb.query('A').filter.type("severity_text = 'WARN'");
await qb.query('A').filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe("severity_text = 'WARN'");
await expect(qb.logsResults()).not.toContainText('mocked query failure');
});
test('TC-35 an empty result leaves the query unchanged', async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER.logs);
await mockQueryRange(page, QueryRange.empty());
await qb.query('A').filter.type("service.name = 'nothing-here'");
await qb.query('A').filter.press('Escape');
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"service.name = 'nothing-here'",
);
await expect
.poll(() => qb.query('A').filter.expression())
.toBe("service.name = 'nothing-here'");
expect(qb.console.errors).toEqual([]);
});
});

View File

@@ -0,0 +1,194 @@
import { expect, EXPLORER, test } from '../../drivers/query-builder';
// A shared or hand-edited explorer link must never take the page down: the
// param is parsed, repaired and rewritten before the builder mounts.
const MALFORMED: Record<string, string> = {
'not JSON': 'not-json',
'truncated JSON': '%7B',
'a JSON string': encodeURIComponent('"a string"'),
'a JSON array': encodeURIComponent('[]'),
'JSON null': 'null',
'an empty JSON string': '%22%22',
'double encoded': encodeURIComponent(
encodeURIComponent(JSON.stringify({ queryType: 'builder' })),
),
};
const PARTIAL: Record<string, unknown> = {
'an empty object': {},
'builder with no builder block': { queryType: 'builder' },
'builder with no queryData': { queryType: 'builder', builder: {} },
'empty queryData': {
queryType: 'builder',
builder: { queryData: [], queryFormulas: [] },
},
'a query with no dataSource': {
queryType: 'builder',
builder: { queryData: [{ queryName: 'A' }] },
},
'an unknown queryType': { queryType: 'zzz' },
'non-array promql and clickhouse blocks': {
queryType: 'builder',
promql: {},
clickhouse_sql: 'x',
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
},
};
/**
* Saved before the v5 builder: structured filters, a having array and an
* aggregate operator. The old having filter wrote `columnName` upper-cased
* (`COUNT()`).
*/
const legacyLogsQuery = (having: unknown[]): unknown => ({
queryType: 'builder',
promql: [],
clickhouse_sql: [],
builder: {
queryFormulas: [],
queryData: [
{
queryName: 'A',
dataSource: 'logs',
expression: 'A',
disabled: false,
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '', isColumn: false },
filters: {
op: 'AND',
items: [
{
id: 'f1',
key: {
key: 'severity_text',
dataType: 'string',
type: '',
isColumn: true,
},
op: '=',
value: 'ERROR',
},
],
},
having,
groupBy: [],
orderBy: [],
functions: [],
legend: '',
limit: null,
stepInterval: 60,
reduceTo: 'avg',
},
],
},
});
const FIRST = "service.name = 'frontend'";
const SECOND = "service.name = 'cartservice'";
interface UrlQuery {
builder?: { queryData?: { filter?: { expression?: string } }[] };
}
test.describe('Query builder — URL handling', () => {
for (const [label, raw] of Object.entries(MALFORMED)) {
test(`TC-01 logs explorer survives ${label} and still runs`, async ({
qb,
}) => {
await qb.openExplorer(EXPLORER.logs, { raw });
await expect(qb.query('A').filter.content()).toBeVisible();
await qb.query('A').filter.type("severity_text = 'ERROR'");
const fired = await qb.run();
expect(qb.spec(fired, 'A').filter?.expression).toBe(
"severity_text = 'ERROR'",
);
expect(qb.urlQuery()).toMatchObject({ queryType: 'builder' });
expect(qb.console.errors).toEqual([]);
});
}
for (const [label, query] of Object.entries(PARTIAL)) {
test(`TC-02 logs explorer repairs ${label}`, async ({ qb }) => {
const loaded = await qb.openExplorer(EXPLORER.logs, { query });
expect(qb.spec(loaded, 'A').signal).toBe('logs');
await expect
.poll(() => qb.urlQuery())
.toMatchObject({
queryType: 'builder',
builder: { queryData: [{ queryName: 'A', dataSource: 'logs' }] },
});
expect(qb.console.errors).toEqual([]);
});
}
test('TC-03 legacy filters and aggregate operator convert to v5 expressions', async ({
qb,
}) => {
const loaded = await qb.openExplorer(EXPLORER.logs, {
view: 'graph',
query: legacyLogsQuery([]),
});
const spec = qb.spec(loaded, 'A');
expect(spec.filter?.expression).toMatch(/^severity_text\s*=\s*'ERROR'$/);
expect(spec.aggregations?.[0]?.expression).toBe('count()');
expect(spec).not.toHaveProperty('filters');
await expect
.poll(() => qb.query('A').filter.expression())
.toMatch(/^severity_text\s*=\s*'ERROR'$/);
expect(qb.console.errors).toEqual([]);
});
test('TC-03 a legacy having as the old builder stored it still runs', async ({
qb,
}) => {
const loaded = await qb.openExplorer(EXPLORER.logs, {
view: 'graph',
query: legacyLogsQuery([{ columnName: 'COUNT()', op: '>', value: 5 }]),
});
expect(qb.spec(loaded, 'A').having?.expression).toBe('count() > 5');
expect(qb.console.failedResponses).toEqual([]);
});
for (const explorer of ['logs', 'traces'] as const) {
test(`TC-04 ${explorer} explorer query follows back, forward and reload`, async ({
authedPage: page,
qb,
}) => {
await qb.openExplorer(EXPLORER[explorer]);
const filter = qb.query('A').filter;
const urlFilter = (): unknown =>
(qb.urlQuery() as UrlQuery | null)?.builder?.queryData?.[0]?.filter
?.expression;
await filter.type(FIRST);
await qb.run();
await filter.type(SECOND);
await qb.run();
await page.goBack();
await expect.poll(urlFilter).toBe(FIRST);
await expect.poll(() => filter.expression()).toBe(FIRST);
await page.goForward();
await expect.poll(urlFilter).toBe(SECOND);
await expect.poll(() => filter.expression()).toBe(SECOND);
await filter.type("service.name = 'unsaved'");
const sentBefore = qb.sent().length;
await page.reload();
await expect.poll(() => filter.expression()).toBe(SECOND);
await expect
.poll(() => qb.sent().slice(sentBefore).length)
.toBeGreaterThan(0);
expect(qb.spec(qb.sent().slice(sentBefore), 'A').filter?.expression).toBe(
SECOND,
);
});
}
});

View File

@@ -240,12 +240,14 @@ def test_json_body_unknown_key_aggregation_over_body(
assert tuple(data[0]) == (6, 4), data[0]
@pytest.mark.parametrize("prefix", ["", "body."], ids=["bare", "body_prefix"])
def test_json_body_unknown_key_body_path_parity(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[JSONPathType]], None],
prefix: str,
) -> None:
"""
Setup:
@@ -254,10 +256,11 @@ def test_json_body_unknown_key_body_path_parity(
(distributed_field_keys); unknown_field is left unregistered.
Tests:
Grouping/filtering by the registered body path (known_field, metadata-driven) and
by the unregistered one (unknown_field, synthesized body branch) return identical
results — the synthesize fallback reproduces exported-metadata resolution for body
paths, the body counterpart of the attribute parity in 13_unknown_key_collisions.py.
Grouping, counting distinct values, and selecting by the registered body path
(known_field, metadata-driven) and by the unregistered one (unknown_field,
fallback body path) return identical results, for the bare key and the
body.-prefixed key — the body counterpart of the attribute parity in
13_unknown_key_collisions.py.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs = []
@@ -279,7 +282,10 @@ def test_json_body_unknown_key_body_path_parity(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
counts: dict[str, dict[str, int]] = {}
distinct: dict[str, int] = {}
selected: dict[str, list[str]] = {}
for key in ("known_field", "unknown_field"):
field = f"{prefix}{key}"
response = make_scalar_query_request(
signoz,
token,
@@ -289,13 +295,42 @@ def test_json_body_unknown_key_body_path_parity(
"A",
"logs",
[build_aggregation("count()")],
group_by=[build_group_by_field(key, field_data_type="", field_context="")],
group_by=[build_group_by_field(field, field_data_type="", field_context="")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{key}: {response.text}"
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
counts[key] = {row[0]: row[-1] for row in get_scalar_table_data(response.json())}
response = make_scalar_query_request(
signoz,
token,
now,
[
build_scalar_query(
"A",
"logs",
[build_aggregation(f"count_distinct({field})")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
distinct[key] = get_scalar_table_data(response.json())[0][-1]
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", limit=100, filter_expression=f'service.name = "{SERVICE}"', select_fields=[{"name": field}])],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
selected[key] = sorted(row["data"][key] for row in get_rows(response))
assert counts["known_field"] == counts["unknown_field"], f"registered {counts['known_field']} != synthesized {counts['unknown_field']}"
assert counts["known_field"] == {"alpha": 2, "beta": 1}, counts["known_field"]
assert distinct == {"known_field": 2, "unknown_field": 2}, distinct
assert selected == {"known_field": ["alpha", "alpha", "beta"], "unknown_field": ["alpha", "alpha", "beta"]}, selected

View File

@@ -1,6 +1,9 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
@@ -10,10 +13,16 @@ from fixtures.querier import (
Aggregation,
BuilderQuery,
MetricAggregation,
OrderBy,
RequestType,
TelemetryFieldKey,
get_all_warnings,
make_preview_query_request,
make_query_request,
)
ORG_ID_NOT_FOUND = "key `orgId` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."
def test_resource_default_warning(
signoz: types.SigNoz,
@@ -384,3 +393,49 @@ def test_no_warnings_for_meter_query(
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert "warning" not in response.json()["data"]
@pytest.mark.parametrize(
"request_type,query_args,expected_warnings",
[
pytest.param(RequestType.RAW, {"select_fields": [TelemetryFieldKey(name="orgId")]}, [ORG_ID_NOT_FOUND], id="select_field"),
pytest.param(RequestType.RAW, {"order": [OrderBy(key=TelemetryFieldKey(name="orgId"))]}, [ORG_ID_NOT_FOUND], id="order_by"),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="count_distinct(orgId)")]}, [ORG_ID_NOT_FOUND], id="aggregation_argument"),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="countIf(orgId = 'acme')")]}, [ORG_ID_NOT_FOUND], id="aggregation_predicate"),
pytest.param(
RequestType.SCALAR,
{"aggregations": [Aggregation(expression="count()")], "group_by": [TelemetryFieldKey(name="orgId")], "filter_expression": "orgId = 'acme'"},
[ORG_ID_NOT_FOUND],
id="filter_and_group_by",
),
pytest.param(RequestType.SCALAR, {"aggregations": [Aggregation(expression="count()")], "group_by": [TelemetryFieldKey(name="service.name")]}, [], id="known_key"),
],
)
def test_logs_unknown_key_outside_the_filter_warns(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
request_type: str,
query_args: dict[str, Any],
expected_warnings: list[str],
) -> None:
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "java"}, body="checkout failed for orgId=acme")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
queries = [BuilderQuery(name="A", signal="logs", **query_args).to_dict()]
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=request_type, queries=queries)
assert response.status_code == HTTPStatus.OK, response.text
assert get_all_warnings(response.json()) == [{"message": message} for message in expected_warnings], response.text
# the preview reports the same key warnings for the same payload
preview = make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type=request_type, queries=queries)
assert preview.status_code == HTTPStatus.OK, preview.text
preview_warnings = preview.json()["data"]["compositeQuery"]["A"]["warnings"]
assert [warning for warning in preview_warnings if "not found in metadata" in warning] == expected_warnings, preview.text

View File

@@ -137,6 +137,46 @@ def test_logs_filter_expression_with_dollar_in_key(
assert {row["data"]["body"] for row in get_rows(response)} == expected_bodies, f"{expression}: {response.text}"
def test_logs_aggregation_with_dollar_in_key(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=3), resources={"service.name": SERVICE}, attributes={"at$0dollar": "alpha-dollar", "at${x}": "a$0b", "n$0um": 5}, body="alpha body"),
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": SERVICE}, attributes={"at$0dollar": "beta-dollar", "at${x}": "it's", "n$0um": 7}, body="beta body"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": SERVICE}, attributes={"plain": "gamma"}, body="gamma body"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=2)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=1)).timestamp() * 1000)
for expression, expected in [
("count_distinct(at$0dollar)", 2),
("count(DISTINCT at$0dollar)", 2),
("sum(n$0um)", 12),
("countIf(at$0dollar EXISTS)", 2),
("count_distinct(at${x})", 2),
("countIf(at${x} = 'a$0b')", 1),
("countIf(at${x} = 'it\\'s')", 1),
("sumIf(n$0um, at$0dollar = 'beta-dollar')", 7),
]:
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[build_scalar_query("A", "logs", [build_aggregation(expression)], filter_expression=f"service.name = '{SERVICE}'")],
request_type="scalar",
)
assert response.status_code == HTTPStatus.OK, f"{expression}: {response.text}"
assert get_scalar_table_data(response.json()) == [[expected]], f"{expression}: {response.text}"
@pytest.mark.parametrize("key_id", list(RESOURCE_KEYS), ids=list(RESOURCE_KEYS))
def test_logs_group_by_hostile_resource_attribute(
signoz: types.SigNoz,

View File

@@ -0,0 +1,69 @@
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

@@ -128,6 +128,172 @@ def test_logs_aggregate_functions(
assert by_service[service] == pytest.approx(expected), f"{service}: {by_service[service]} != {expected}"
def test_logs_aggregate_if_predicates_use_filter_syntax(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
svc-a: 3 logs, two with a k8s.pod.name and an x-request-id header, two
with "timeout" in the body; svc-b: 1 log without either, with "timeout".
Tests:
countIf predicates accept the filter-only operators EXISTS, NOT EXISTS,
CONTAINS and NOT CONTAINS, a combination of them, and a key with hyphens;
two predicates in one expression each keep their own values.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# (service, k8s.pod.name, x-request-id header, body)
specs = [
("svc-a", "pod-1", "abc", "request timeout"),
("svc-a", None, None, "request ok"),
("svc-a", "pod-2", "def", "upstream timeout"),
("svc-b", None, None, "timeout"),
]
logs = [
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service} | ({"k8s.pod.name": pod} if pod else {}),
attributes={"http.request.header.x-request-id": request_id} if request_id else {},
body=body,
)
for i, (service, pod, request_id, body) in enumerate(specs)
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="logs",
aggregations=[
build_aggregation("count()", "cnt"),
build_aggregation("countIf(k8s.pod.name EXISTS)", "with_pod"),
build_aggregation("countIf(k8s.pod.name NOT EXISTS)", "without_pod"),
build_aggregation("countIf(body CONTAINS 'timeout')", "timeouts"),
build_aggregation("countIf(body NOT CONTAINS 'timeout')", "not_timeouts"),
build_aggregation("countIf(body CONTAINS 'timeout' AND k8s.pod.name EXISTS)", "pod_timeouts"),
build_aggregation("countIf(http.request.header.x-request-id = 'abc')", "request_abc"),
build_aggregation("countIf(k8s.pod.name = 'pod-1') + countIf(body CONTAINS 'ok')", "pod_1_or_ok"),
],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count()", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
assert [row[0] for row in data] == ["svc-a", "svc-b"]
by_service = {row[0]: row[1:] for row in data}
for service in ["svc-a", "svc-b"]:
rows = [(pod, request_id, body) for svc, pod, request_id, body in specs if svc == service]
expected = [
len(rows),
sum(1 for pod, _, _ in rows if pod),
sum(1 for pod, _, _ in rows if not pod),
sum(1 for _, _, body in rows if "timeout" in body),
sum(1 for _, _, body in rows if "timeout" not in body),
sum(1 for pod, _, body in rows if "timeout" in body and pod),
sum(1 for _, request_id, _ in rows if request_id == "abc"),
sum(1 for pod, _, _ in rows if pod == "pod-1") + sum(1 for _, _, body in rows if "ok" in body),
]
assert by_service[service] == expected, f"{service}: {by_service[service]} != {expected}"
def test_logs_aggregate_arguments_are_field_names(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
svc-a: 3 logs, svc-b: 1 log, each with a body size under a key with a
hyphen and an app label under a key with slashes.
Tests:
Aggregation arguments are read as field names, so keys with hyphens,
slashes and a type suffix resolve, count(*) counts every log, and DISTINCT
keeps its meaning.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# (service, http.response.body-size, k8s.pod.labels.app.kubernetes.io/name)
specs = [
("svc-a", 100, "web"),
("svc-a", 250, "web"),
("svc-a", 400, "api"),
("svc-b", 70, "worker"),
]
logs = [
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service},
attributes={"http.response.body-size": size, "k8s.pod.labels.app.kubernetes.io/name": app},
body=f"{service} log {i}",
)
for i, (service, size, app) in enumerate(specs)
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="logs",
aggregations=[
build_aggregation("count(*)", "cnt"),
build_aggregation("sum(http.response.body-size)", "body_size"),
build_aggregation("max(http.response.body-size:float64)", "max_body_size"),
build_aggregation("count_distinct(k8s.pod.labels.app.kubernetes.io/name)", "apps"),
build_aggregation("count(DISTINCT k8s.pod.labels.app.kubernetes.io/name)", "distinct_apps"),
],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count(*)", "desc")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK, response.text
data = get_scalar_table_data(response.json())
assert [row[0] for row in data] == ["svc-a", "svc-b"]
by_service = {row[0]: row[1:] for row in data}
for service in ["svc-a", "svc-b"]:
sizes = [size for svc, size, _ in specs if svc == service]
apps = {app for svc, _, app in specs if svc == service}
expected = [len(sizes), sum(sizes), max(sizes), len(apps), len(apps)]
assert by_service[service] == expected, f"{service}: {by_service[service]} != {expected}"
@pytest.mark.parametrize(
"expression",
[
pytest.param("countIf(k8s.pod.name EXISTS", id="unclosed_parenthesis"),
pytest.param("countIf()", id="missing_predicate"),
pytest.param("countIf(latency_ms >= 25, latency_ms < 100)", id="extra_argument"),
pytest.param("sum(1)", id="literal_argument"),
pytest.param("sum(latency_ms * 2)", id="arithmetic_in_argument"),
pytest.param("sum(attributes_number['latency_ms'])", id="column_expression_argument"),
pytest.param("countIf(search('timeout'))", id="search_in_predicate"),
],
)
def test_logs_aggregate_invalid_expression_is_bad_request(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
expression: str,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation(expression)])
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
@pytest.mark.parametrize(
"having_expression,aggregations,expected_services",
[

View File

@@ -10,8 +10,8 @@ from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCo
# Unknown-key resolution in the scalar shape (group-by -> one value per bucket). Logs/traces
# synthesize an unknown key (attribute maps + body). Observed here: the *filter* path emits a
# key-not-found warning and matches nothing (count 0); the *group-by* path silently buckets
# every record into a single NULL bucket and emits no warning.
# key-not-found warning and matches nothing (count 0); the *group-by* path buckets every record
# into a single NULL bucket and emits the same warning.
UNKNOWN = "totally.unknown.key"
@@ -84,7 +84,7 @@ def test_logs_scalar_group_by_unknown_key_null_bucket(
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""Grouping a scalar count() by an unknown bare key synthesizes a NULL column: all records
collapse into one bucket (200), and — unlike the filter path — no key-not-found warning."""
collapse into one bucket (200), with the same key-not-found warning as the filter path."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "scalar-unknown-gb-logs"
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": service}, body=f"log {i}") for i in range(4)])
@@ -106,7 +106,7 @@ def test_logs_scalar_group_by_unknown_key_null_bucket(
)
assert response.status_code == HTTPStatus.OK, response.text
messages = [w.get("message", "") for w in get_all_warnings(response.json())]
assert not any(UNKNOWN in m for m in messages), messages
assert any(UNKNOWN in m and "not found" in m for m in messages), messages
data = get_scalar_table_data(response.json())
assert len(data) == 1, data
assert data[0][-1] == 4, data
@@ -163,7 +163,7 @@ def test_traces_scalar_group_by_unknown_key_null_bucket(
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Grouping a scalar count() by an unknown bare key synthesizes a NULL column: all spans
collapse into one bucket (200), and — unlike the filter path — no key-not-found warning."""
collapse into one bucket (200), with the same key-not-found warning as the filter path."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "scalar-unknown-gb-traces"
insert_traces(
@@ -198,7 +198,7 @@ def test_traces_scalar_group_by_unknown_key_null_bucket(
)
assert response.status_code == HTTPStatus.OK, response.text
messages = [w.get("message", "") for w in get_all_warnings(response.json())]
assert not any(UNKNOWN in m for m in messages), messages
assert any(UNKNOWN in m and "not found" in m for m in messages), messages
data = get_scalar_table_data(response.json())
assert len(data) == 1, data
assert data[0][-1] == 4, data