mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-10 02:10:42 +00:00
Compare commits
10 Commits
fix/alerts
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1b3092d5 | ||
|
|
d6017f88fd | ||
|
|
9d28014664 | ||
|
|
1997901102 | ||
|
|
84268d00c8 | ||
|
|
628e989bc0 | ||
|
|
74d272a945 | ||
|
|
f8421e81e9 | ||
|
|
e938a10ad5 | ||
|
|
93cc1957d4 |
9
.github/CODEOWNERS
vendored
9
.github/CODEOWNERS
vendored
@@ -209,6 +209,10 @@ go.mod @therealpandey
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -217,6 +221,11 @@ go.mod @therealpandey
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## List filter + views (dashboards and alerts lists)
|
||||
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
@@ -10231,8 +10231,10 @@ components:
|
||||
having:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
description: Accepted but currently ignored; the computed values are returned
|
||||
unfiltered.
|
||||
description: A filter on the formula values, referencing the formula by
|
||||
its name or as __result, for example F1 > 0. A time series keeps only
|
||||
the points that match and drops a series with none left; a scalar result
|
||||
keeps only the rows that match.
|
||||
legend:
|
||||
description: The display name template for each series; {{field}} placeholders
|
||||
are replaced by label values.
|
||||
@@ -32729,6 +32731,305 @@ paths:
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_anomaly_request_rate:
|
||||
description: 'anomaly is the one function with named arguments. seasonality
|
||||
says which past periods predict the current value: hourly, daily
|
||||
or weekly, daily when absent. z_score_threshold is how many standard
|
||||
deviations from the prediction count as an anomaly. It has no default:
|
||||
a request without it gets bounds of zero width, so send it. 3 is
|
||||
the value the UI sends. The response carries, next to the values,
|
||||
a predicted series, an upper and a lower bound series and an anomaly
|
||||
score series. The scoring runs in the enterprise build; the community
|
||||
build returns the series unchanged.'
|
||||
summary: 'anomaly: request rate scored against its daily pattern'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- args:
|
||||
- name: seasonality
|
||||
value: daily
|
||||
- name: z_score_threshold
|
||||
value: 3
|
||||
name: anomaly
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 300s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cumulativesum_errors_so_far:
|
||||
description: cumulativeSum replaces each point with the sum of every
|
||||
point up to and including it, so a per-step count becomes a running
|
||||
total whose first point is the first count. It takes no arguments.
|
||||
The total resets with the window, so a longer time range gives a
|
||||
larger final value; it is a view of the window, not a counter.
|
||||
summary: 'cumulativeSum: 5xx responses since the start of the window'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: server_errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: http.response.status_code >= 500
|
||||
functions:
|
||||
- name: cumulativeSum
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_cutoffmin_hide_idle_hosts:
|
||||
description: The four threshold functions take one number. cutOffMin
|
||||
and cutOffMax remove the points below or above the threshold, so
|
||||
an idle host disappears from the chart instead of drawing a line
|
||||
along the bottom. clampMin and clampMax keep the points and replace
|
||||
the value with the threshold, which caps spikes without hiding them.
|
||||
system.cpu.utilization is a ratio, so 0.05 is 5%.
|
||||
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.cpu.utilization
|
||||
spaceAggregation: avg
|
||||
timeAggregation: avg
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.05
|
||||
name: cutOffMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_ewma_smoothed_latency:
|
||||
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
|
||||
weighted average of the point and the points before it. The number
|
||||
in the name is the window the default weight is derived from: alpha
|
||||
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
|
||||
alpha; a value close to 1 follows the raw series, a value close
|
||||
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
|
||||
default.'
|
||||
summary: 'ewma5: smoothed p95 latency'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.bucket
|
||||
spaceAggregation: p95
|
||||
functions:
|
||||
- args:
|
||||
- value: 0.3
|
||||
name: ewma5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_fillzero_sparse_errors:
|
||||
description: A count over sparse events returns a point only for steps
|
||||
that had a match, so a chart of failed spans has gaps where nothing
|
||||
failed. fillZero adds a zero at every missing step. With no arguments
|
||||
the server fills the request window at the query's step, which is
|
||||
the common case. The three optional arguments are start, end and
|
||||
step in milliseconds, for callers that want to fill a different
|
||||
grid.
|
||||
summary: 'fillZero: a zero at every step with no failed spans'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: failed_spans
|
||||
expression: count()
|
||||
filter:
|
||||
expression: has_error = true
|
||||
functions:
|
||||
- name: fillZero
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: traces
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_log10_network_bytes:
|
||||
description: log2 and log10 replace each value with its logarithm,
|
||||
so a loopback device moving kilobytes and an uplink moving gigabytes
|
||||
fit on one chart. They take no arguments. Zero and negative values
|
||||
have no logarithm and are not drawn. absolute, also without arguments,
|
||||
is the usual companion for a signed series before a logarithm.
|
||||
summary: 'log10: network throughput across devices that differ by
|
||||
orders of magnitude'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.network.io
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
filter:
|
||||
expression: direction = 'transmit'
|
||||
functions:
|
||||
- name: log10
|
||||
groupBy:
|
||||
- fieldContext: attribute
|
||||
name: device
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_median_despiked_request_rate:
|
||||
description: median3, median5 and median7 replace each point with
|
||||
the median of the surrounding 3, 5 or 7 points. A spike that lasts
|
||||
one step vanishes, a shift that lasts longer than half the window
|
||||
stays. The first and last half window, 2 points each for median5,
|
||||
have no full window and keep their values, so the newest spike stays
|
||||
visible; a series shorter than the window is returned unchanged.
|
||||
They take no arguments. Use them when the raw series is noisy and
|
||||
the chart is for a trend, not for catching the spike.
|
||||
summary: 'median5: request rate without one-step spikes'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: http.server.duration.count
|
||||
spaceAggregation: sum
|
||||
timeAggregation: rate
|
||||
functions:
|
||||
- name: median5
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: service.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_runningdiff_disk_growth:
|
||||
description: 'runningDiff turns a gauge into the change between consecutive
|
||||
points, so a filesystem usage gauge becomes bytes written per step.
|
||||
It takes no arguments and drops the first point of the window, since
|
||||
that point has nothing to difference against; the server reads one
|
||||
step before the window to keep the first interval. Functions apply
|
||||
in order: clampMin with 0 runs on the differences and replaces the
|
||||
negative value a cleanup or a volume reset produces with zero.'
|
||||
summary: 'runningDiff then clampMin: bytes written to disk per step'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- metricName: system.filesystem.usage
|
||||
spaceAggregation: sum
|
||||
timeAggregation: latest
|
||||
filter:
|
||||
expression: state = 'used'
|
||||
functions:
|
||||
- name: runningDiff
|
||||
- args:
|
||||
- value: 0
|
||||
name: clampMin
|
||||
groupBy:
|
||||
- fieldContext: resource
|
||||
name: host.name
|
||||
name: A
|
||||
signal: metrics
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
function_timeshift_week_over_week:
|
||||
description: timeShift moves every point of its query forward by the
|
||||
argument, in seconds, so a series read from the past lands on the
|
||||
current timestamps. B here reads the hour exactly seven days (604800
|
||||
s) before the requested window and comes back on this week's timestamps,
|
||||
so it overlays A on a chart and the formula can subtract it point
|
||||
by point. The argument is positive; it says how far back to read.
|
||||
summary: 'timeShift: this week''s error logs next to last week''s'
|
||||
value:
|
||||
compositeQuery:
|
||||
queries:
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
name: A
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
aggregations:
|
||||
- alias: errors
|
||||
expression: count()
|
||||
filter:
|
||||
expression: severity_text = 'ERROR'
|
||||
functions:
|
||||
- args:
|
||||
- value: 604800
|
||||
name: timeShift
|
||||
name: B
|
||||
signal: logs
|
||||
stepInterval: 60s
|
||||
type: builder_query
|
||||
- spec:
|
||||
expression: A - B
|
||||
name: F1
|
||||
type: builder_formula
|
||||
end: 1640998800000
|
||||
requestType: time_series
|
||||
schemaVersion: v1
|
||||
start: 1640995200000
|
||||
logs_raw:
|
||||
summary: 'Raw: fetch raw log records'
|
||||
value:
|
||||
|
||||
@@ -7079,7 +7079,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @description Accepted but currently ignored; the computed values are returned unfiltered.
|
||||
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
|
||||
*/
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
|
||||
@@ -21,14 +21,15 @@ import {
|
||||
useListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import {
|
||||
getListRulesQueryKey,
|
||||
useListRules,
|
||||
getListRulesV3QueryKey,
|
||||
useListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type {
|
||||
DashboardtypesListedDashboardForUserV2DTO,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useQueryService } from 'hooks/useQueryService';
|
||||
@@ -179,6 +180,9 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
|
||||
*/
|
||||
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
|
||||
|
||||
/** Same as dashboards. 200 is the most the rules list returns per call. */
|
||||
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
|
||||
|
||||
function dashboardTitle(
|
||||
dashboard: DashboardtypesListedDashboardForUserV2DTO,
|
||||
): string {
|
||||
@@ -755,7 +759,7 @@ export default function ChatInput({
|
||||
data: alertsResponse,
|
||||
isLoading: isAlertsLoading,
|
||||
isError: isAlertsError,
|
||||
} = useListRules({
|
||||
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
|
||||
query: {
|
||||
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
|
||||
staleTime: Infinity,
|
||||
@@ -804,10 +808,10 @@ export default function ChatInput({
|
||||
}
|
||||
}
|
||||
if (ctx.type === 'alert' && ctx.resourceId) {
|
||||
const cached = queryClient.getQueryData<ListRules200>(
|
||||
getListRulesQueryKey(),
|
||||
const cached = queryClient.getQueryData<ListRulesV3200>(
|
||||
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
|
||||
);
|
||||
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
|
||||
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
|
||||
if (rule?.alert) {
|
||||
return rule.alert;
|
||||
}
|
||||
@@ -835,8 +839,8 @@ export default function ChatInput({
|
||||
value: dashboardTitle(dashboard),
|
||||
})) ?? [],
|
||||
Alerts:
|
||||
alertsResponse?.data
|
||||
?.filter((alertRule) => Boolean(alertRule.alert))
|
||||
alertsResponse?.data.rules
|
||||
.filter((alertRule) => Boolean(alertRule.alert))
|
||||
.map((alertRule) => ({
|
||||
id: alertRule.id,
|
||||
value: alertRule.alert,
|
||||
@@ -1059,6 +1063,7 @@ export default function ChatInput({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
id={`ai-context-tab-${category}`}
|
||||
testId={`ai-context-tab-${category}`}
|
||||
// Single stable panel id shared by every tab: only the
|
||||
// active category's tabpanel is rendered, so per-category
|
||||
// `aria-controls` ids would point at nonexistent nodes
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useListDashboardsForUserV2: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
useQueryService: (): unknown => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useSpeechRecognition', () => ({
|
||||
useSpeechRecognition: (): unknown => ({
|
||||
isListening: false,
|
||||
isSupported: false,
|
||||
permission: 'prompt',
|
||||
start: jest.fn(),
|
||||
discard: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
|
||||
useAIAssistantAnalyticsContext: (): unknown => ({
|
||||
threadId: undefined,
|
||||
page: '/',
|
||||
mode: 'sidepane',
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import ChatInput from '../ChatInput';
|
||||
|
||||
describe('ChatInput — alerts context picker', () => {
|
||||
it('lists the rules from one v3 rules request', async () => {
|
||||
const [first, second] = alertRulesFixture;
|
||||
const requests: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
requests.push(req.url.search);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(<ChatInput onSend={jest.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
|
||||
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
|
||||
|
||||
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText(second.alert)).toBeInTheDocument();
|
||||
expect(requests).toStrictEqual(['?limit=200']);
|
||||
});
|
||||
});
|
||||
@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
|
||||
getListRulesQueryKey: (): string[] => ['rules'],
|
||||
useListRulesV3: (): unknown => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
getListRulesV3QueryKey: (): string[] => ['rules'],
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useQueryService', () => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
|
||||
import './styles.scss';
|
||||
import {
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
|
||||
{
|
||||
onSuccess: () => {
|
||||
void invalidateGetRuleByID(queryClient, { id: ruleId });
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
|
||||
toast.success('Alert rule updated successfully');
|
||||
safeNavigate('/alerts');
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppLink } from 'lib/router/AppLink';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import { useAppLocation } from 'lib/router/useAppLocation';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import beaconUrl from '@/assets/Icons/beacon.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
import { useHomeAlertRules } from './useHomeAlertRules';
|
||||
|
||||
export default function AlertRules({
|
||||
onUpdateChecklistDoneItem,
|
||||
@@ -28,49 +26,19 @@ export default function AlertRules({
|
||||
loadingUserPreferences: boolean;
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [rulesExist, setRulesExist] = useState(false);
|
||||
|
||||
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const location = useAppLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Fetch Alerts
|
||||
const {
|
||||
data: alerts,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
|
||||
const rulesExist = alertRules.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const rules = alerts?.data ?? [];
|
||||
setRulesExist(rules.length > 0);
|
||||
|
||||
const sortedRules = [...rules].sort((a, b) => {
|
||||
// First, prioritize firing alerts
|
||||
if (a.state === 'firing' && b.state !== 'firing') {
|
||||
return -1;
|
||||
}
|
||||
if (a.state !== 'firing' && b.state === 'firing') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Then sort by updatedAt timestamp
|
||||
return (
|
||||
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
if (sortedRules.length > 0 && !loadingUserPreferences) {
|
||||
if (rulesExist && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_ALERTS');
|
||||
}
|
||||
|
||||
setSortedAlertRules(sortedRules.slice(0, 5));
|
||||
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -95,7 +63,7 @@ export default function AlertRules({
|
||||
className="periscope-btn secondary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Create alert rule clicked', {});
|
||||
void logEvent('Homepage: Create alert rule clicked', {});
|
||||
}}
|
||||
>
|
||||
Create Alert Rule
|
||||
@@ -106,7 +74,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Learn more clicked', {
|
||||
void logEvent('Homepage: Learn more clicked', {
|
||||
source: 'Alert Rules',
|
||||
});
|
||||
|
||||
@@ -125,26 +93,13 @@ export default function AlertRules({
|
||||
</div>
|
||||
);
|
||||
|
||||
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
|
||||
logEvent('Homepage: Alert clicked', {
|
||||
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
|
||||
void logEvent('Homepage: Alert clicked', {
|
||||
ruleId: record.id,
|
||||
ruleName: record.alert,
|
||||
ruleState: record.state,
|
||||
});
|
||||
|
||||
const compositeQuery = mapQueryDataFromApi(
|
||||
toCompositeMetricQuery(record.condition.compositeQuery),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const panelType = record.condition.compositeQuery.panelType;
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, panelType);
|
||||
}
|
||||
|
||||
params.set(QueryParams.ruleId, record.id);
|
||||
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -153,11 +108,12 @@ export default function AlertRules({
|
||||
const renderAlertRules = (): JSX.Element => (
|
||||
<div className="alert-rules-container home-data-item-container">
|
||||
<div className="alert-rules-list">
|
||||
{sortedAlertRules.map((rule) => (
|
||||
{alertRules.map((rule) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="alert-rule-item home-data-item"
|
||||
data-testid={`home-alert-rule-${rule.id}`}
|
||||
key={rule.id}
|
||||
onClick={onEditHandler(rule)}
|
||||
onKeyDown={(e): void => {
|
||||
@@ -239,7 +195,7 @@ export default function AlertRules({
|
||||
type="link"
|
||||
className="periscope-btn link learn-more-link"
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: All alert rules clicked', {});
|
||||
void logEvent('Homepage: All alert rules clicked', {});
|
||||
}}
|
||||
>
|
||||
All Alert Rules <ArrowRight size={12} />
|
||||
|
||||
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
64
frontend/src/container/Home/AlertRules/useHomeAlertRules.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useListRulesV3 } from 'api/generated/services/rules';
|
||||
import {
|
||||
type ListRulesV3200,
|
||||
type ListRulesV3Params,
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
|
||||
|
||||
const HOME_ALERT_RULES_LIMIT = 5;
|
||||
|
||||
const FIRING_RULES_PARAMS: ListRulesV3Params = {
|
||||
states: [RuletypesAlertStateDTO.firing],
|
||||
limit: HOME_ALERT_RULES_LIMIT,
|
||||
};
|
||||
|
||||
interface HomeAlertRules {
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function useHomeAlertRules(): HomeAlertRules {
|
||||
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
|
||||
queryFn: ({ signal }) =>
|
||||
GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: '/api/v3/rules',
|
||||
method: 'GET',
|
||||
params: FIRING_RULES_PARAMS,
|
||||
paramsSerializer: { indexes: null },
|
||||
signal,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const firingRules = firing.data?.data.rules;
|
||||
const latest = useListRulesV3(
|
||||
{ limit: HOME_ALERT_RULES_LIMIT },
|
||||
{
|
||||
query: {
|
||||
cacheTime: 0,
|
||||
enabled:
|
||||
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rules = useMemo(() => {
|
||||
const firingIds = new Set(firingRules?.map(({ id }) => id));
|
||||
const others = (latest.data?.data.rules ?? []).filter(
|
||||
({ id }) => !firingIds.has(id),
|
||||
);
|
||||
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
|
||||
}, [firingRules, latest.data]);
|
||||
|
||||
return {
|
||||
rules,
|
||||
isLoading: firing.isLoading || latest.isLoading,
|
||||
isError: firing.isError || latest.isError,
|
||||
};
|
||||
}
|
||||
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
119
frontend/src/container/Home/__tests__/AlertRules.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
RuletypesAlertStateDTO,
|
||||
type RuletypesRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { navigate } from 'lib/router/navigation';
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AlertRules from '../AlertRules/AlertRules';
|
||||
|
||||
jest.mock('lib/router/navigation', () => ({
|
||||
...jest.requireActual('lib/router/navigation'),
|
||||
navigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
|
||||
...alertRulesFixture[0],
|
||||
id,
|
||||
alert: `${id} rule`,
|
||||
state,
|
||||
});
|
||||
|
||||
const burning = rule('burning', RuletypesAlertStateDTO.firing);
|
||||
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
|
||||
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
|
||||
|
||||
const serveRules = (
|
||||
rules: RuletypesRuleDTO[],
|
||||
rulesForLatest = rules,
|
||||
): string[] => {
|
||||
const requestedStates: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
const states = req.url.searchParams.getAll('states').join();
|
||||
requestedStates.push(states);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
listRulesV3Response(
|
||||
states === 'firing' ? rules : rulesForLatest,
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
return requestedStates;
|
||||
};
|
||||
|
||||
const renderAlertRules = (): void => {
|
||||
render(
|
||||
<AlertRules
|
||||
onUpdateChecklistDoneItem={jest.fn()}
|
||||
loadingUserPreferences={false}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderedRuleIds = (): (string | undefined)[] =>
|
||||
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
|
||||
|
||||
describe('Home alert rules', () => {
|
||||
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
|
||||
const requestedStates = serveRules([quiet, burning, waiting]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-waiting'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
'home-alert-rule-waiting',
|
||||
]);
|
||||
expect(requestedStates).toStrictEqual(['firing', '']);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
await user.click(screen.getByTestId('home-alert-rule-burning'));
|
||||
expect(navigate).toHaveBeenCalledWith(
|
||||
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the latest rules request when 5 rules are firing', async () => {
|
||||
const firing = Array.from({ length: 5 }, (_, index) =>
|
||||
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
|
||||
);
|
||||
const requestedStates = serveRules([quiet, ...firing]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-firing-4'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual(
|
||||
firing.map(({ id }) => `home-alert-rule-${id}`),
|
||||
);
|
||||
expect(requestedStates).toStrictEqual(['firing']);
|
||||
});
|
||||
|
||||
it('shows a rule once when it stops firing between the two requests', async () => {
|
||||
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
|
||||
serveRules([burning], [settled, quiet]);
|
||||
renderAlertRules();
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('home-alert-rule-quiet'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(renderedRuleIds()).toStrictEqual([
|
||||
'home-alert-rule-burning',
|
||||
'home-alert-rule-quiet',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
useDeleteDowntimeScheduleByID,
|
||||
useListDowntimeSchedules,
|
||||
} from 'api/generated/services/downtimeschedules';
|
||||
import { useListRules } from 'api/generated/services/rules';
|
||||
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import dayjs from 'dayjs';
|
||||
import { useListAllRules } from 'hooks/alerts/useListAllRules';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
@@ -55,9 +55,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching: isFetchingAlerts,
|
||||
} = useListRules({
|
||||
query: { cacheTime: 0 },
|
||||
});
|
||||
} = useListAllRules({ cacheTime: 0 });
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAppContext();
|
||||
@@ -73,7 +71,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
const alertOptions = React.useMemo(
|
||||
() =>
|
||||
alertsData?.data?.map((i) => ({
|
||||
alertsData?.map((i) => ({
|
||||
label: i.alert,
|
||||
value: i.id,
|
||||
})),
|
||||
@@ -181,7 +179,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
};
|
||||
|
||||
const refetchAllSchedules = (): void => {
|
||||
downtimeSchedules.refetch();
|
||||
void downtimeSchedules.refetch();
|
||||
};
|
||||
|
||||
const {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, screen } from '@testing-library/react';
|
||||
import type {
|
||||
ListDowntimeSchedules200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesListableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AlertmanagertypesRepeatOnDTO,
|
||||
@@ -126,19 +127,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
|
||||
}));
|
||||
|
||||
let mockRulesState: {
|
||||
data?: { data: Array<{ id: number | string; alert: string }> };
|
||||
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
} = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
jest.mock('api/generated/services/rules', () => ({
|
||||
useListRules: (): typeof mockRulesState => mockRulesState,
|
||||
jest.mock('hooks/alerts/useListAllRules', () => ({
|
||||
useListAllRules: (): typeof mockRulesState => mockRulesState,
|
||||
}));
|
||||
|
||||
describe('PlannedDowntime Component', () => {
|
||||
@@ -151,7 +152,7 @@ describe('PlannedDowntime Component', () => {
|
||||
});
|
||||
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -362,7 +363,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
|
||||
mockRulesState = {
|
||||
data: { data: [] },
|
||||
data: [],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
@@ -387,14 +388,7 @@ describe('PlannedDowntime Component', () => {
|
||||
|
||||
it('shows resolved alert rule name when scoped rule ID exists', async () => {
|
||||
mockRulesState = {
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rule-42',
|
||||
alert: 'Disk Space High',
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
|
||||
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
40
frontend/src/hooks/alerts/__tests__/useListAllRules.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
alertRulesFixture,
|
||||
listRulesV3Response,
|
||||
} from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { useListAllRules } from '../useListAllRules';
|
||||
|
||||
const TOTAL = 250;
|
||||
|
||||
const rules = Array.from({ length: TOTAL }, (_, index) => ({
|
||||
...alertRulesFixture[0],
|
||||
id: `rule-${index}`,
|
||||
alert: `Rule ${index}`,
|
||||
}));
|
||||
|
||||
describe('useListAllRules', () => {
|
||||
it('fetches every page past the 200-rule cap', async () => {
|
||||
const offsets: string[] = [];
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
offsets.push(req.url.searchParams.get('offset') ?? '');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useListAllRules(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toHaveLength(TOTAL);
|
||||
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
|
||||
expect(offsets.sort()).toStrictEqual(['0', '200']);
|
||||
});
|
||||
});
|
||||
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
40
frontend/src/hooks/alerts/useListAllRules.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import {
|
||||
getListRulesV3QueryKey,
|
||||
listRulesV3,
|
||||
} from 'api/generated/services/rules';
|
||||
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { range } from 'lodash-es';
|
||||
|
||||
// The list API clamps any larger limit to 200.
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
|
||||
|
||||
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
|
||||
return listRulesV3({ limit: PAGE_SIZE, offset });
|
||||
}
|
||||
|
||||
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
|
||||
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
|
||||
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
|
||||
const first = await fetchPage(0);
|
||||
const rest = await Promise.all(
|
||||
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
|
||||
);
|
||||
|
||||
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
|
||||
}
|
||||
|
||||
export function useListAllRules(
|
||||
options?: Omit<
|
||||
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
|
||||
return useQuery<RuletypesListableRuleDTO[], Error>({
|
||||
queryKey: LIST_ALL_RULES_QUERY_KEY,
|
||||
queryFn: fetchAllRules,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
invalidateListRulesV3,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
|
||||
message: `Success`,
|
||||
});
|
||||
|
||||
await invalidateListRules(queryClient);
|
||||
await invalidateListRulesV3(queryClient);
|
||||
|
||||
params.set(QueryParams.ruleId, response.data.id);
|
||||
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
|
||||
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
|
||||
data: { ...existingRule.data, alert: intermediateName },
|
||||
});
|
||||
}
|
||||
void invalidateListRules(queryClient);
|
||||
void invalidateListRulesV3(queryClient);
|
||||
notifications.success({ message: 'Alert renamed successfully' });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
FIRST_DOWNTIME_NAME,
|
||||
} from './__story_mockdata__/plannedDowntime';
|
||||
|
||||
import {
|
||||
alertRulesResponse,
|
||||
RULE_MAX,
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
|
||||
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
|
||||
import { AlertListSubTabs, AlertListTabs } from '../../types';
|
||||
|
||||
const LIST = 'Planned downtime · list';
|
||||
@@ -86,18 +84,20 @@ export const plannedDowntimeMocks = defineStoryMocks({
|
||||
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
|
||||
values.rulesState === 'error'
|
||||
? res(ctx.status(500), ctx.json({ status: 'error' }))
|
||||
: res(
|
||||
ctx.json(
|
||||
alertRulesResponse(values.silencedRules, {
|
||||
severity: 'mixed',
|
||||
state: 'mixed',
|
||||
}),
|
||||
),
|
||||
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
|
||||
if (values.rulesState === 'error') {
|
||||
return res(ctx.status(500), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.json(
|
||||
alertRulesPage(
|
||||
values.silencedRules,
|
||||
{ severity: 'mixed', state: 'mixed' },
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
config: () => ({
|
||||
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Do not hand-edit: regenerate instead.
|
||||
*/
|
||||
|
||||
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
|
||||
import { rest } from 'msw';
|
||||
|
||||
import {
|
||||
@@ -156,11 +157,13 @@ export const homeMocks = defineStoryMocks({
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v2/rules',
|
||||
response.json(() => ({
|
||||
status: 'success',
|
||||
data: buildAlertRules(values.alertRules),
|
||||
})),
|
||||
'http://localhost/api/v3/rules',
|
||||
response.json((req) =>
|
||||
listRulesV3Response(
|
||||
buildAlertRules(values.alertRules),
|
||||
req.url.searchParams,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
|
||||
@@ -30,7 +30,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
|
||||
Request: new(qbtypes.QueryRangeRequest),
|
||||
RequestContentType: "application/json",
|
||||
RequestExamples: []handler.OpenAPIExample{
|
||||
RequestExamples: append([]handler.OpenAPIExample{
|
||||
{
|
||||
Name: "traces_time_series",
|
||||
Summary: "Time series: count spans grouped by service",
|
||||
@@ -598,7 +598,7 @@ func (provider *provider) addQuerierRoutes(router *mux.Router) error {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, queryRangeFunctionExamples()...),
|
||||
Response: new(qbtypes.QueryRangeResponse),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
|
||||
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
202
pkg/apiserver/signozapiserver/querier_examples.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package signozapiserver
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/http/handler"
|
||||
|
||||
// timeSeriesExample wraps builder query specs in a one hour time series
|
||||
// request, the shape every function example shares.
|
||||
func timeSeriesExample(name, summary, description string, queries ...map[string]any) handler.OpenAPIExample {
|
||||
list := make([]any, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
list = append(list, query)
|
||||
}
|
||||
return handler.OpenAPIExample{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Description: description,
|
||||
Value: map[string]any{
|
||||
"schemaVersion": "v1",
|
||||
"start": 1640995200000,
|
||||
"end": 1640998800000,
|
||||
"requestType": "time_series",
|
||||
"compositeQuery": map[string]any{"queries": list},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func builderQueryEnvelope(spec map[string]any) map[string]any {
|
||||
return map[string]any{"type": "builder_query", "spec": spec}
|
||||
}
|
||||
|
||||
// function builds a function entry with positional arguments; anomaly is the
|
||||
// one function that takes named arguments and is written out in full.
|
||||
func function(name string, args ...any) map[string]any {
|
||||
list := make([]any, 0, len(args))
|
||||
for _, arg := range args {
|
||||
list = append(list, map[string]any{"value": arg})
|
||||
}
|
||||
entry := map[string]any{"name": name}
|
||||
if len(list) > 0 {
|
||||
entry["args"] = list
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// queryRangeFunctionExamples shows every function a builder query can carry,
|
||||
// what its arguments mean and the values they take. Functions run in the
|
||||
// order listed, after the aggregation and before formulas.
|
||||
func queryRangeFunctionExamples() []handler.OpenAPIExample {
|
||||
return []handler.OpenAPIExample{
|
||||
timeSeriesExample(
|
||||
"function_timeshift_week_over_week",
|
||||
"timeShift: this week's error logs next to last week's",
|
||||
"timeShift moves every point of its query forward by the argument, in seconds, so a series read from the past lands on the current timestamps. B here reads the hour exactly seven days (604800 s) before the requested window and comes back on this week's timestamps, so it overlays A on a chart and the formula can subtract it point by point. The argument is positive; it says how far back to read.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
}),
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "B",
|
||||
"signal": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
|
||||
"functions": []any{function("timeShift", 604800)},
|
||||
}),
|
||||
map[string]any{
|
||||
"type": "builder_formula",
|
||||
"spec": map[string]any{"name": "F1", "expression": "A - B"},
|
||||
},
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_fillzero_sparse_errors",
|
||||
"fillZero: a zero at every step with no failed spans",
|
||||
"A count over sparse events returns a point only for steps that had a match, so a chart of failed spans has gaps where nothing failed. fillZero adds a zero at every missing step. With no arguments the server fills the request window at the query's step, which is the common case. The three optional arguments are start, end and step in milliseconds, for callers that want to fill a different grid.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "failed_spans"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "has_error = true"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("fillZero")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cutoffmin_hide_idle_hosts",
|
||||
"cutOffMin: drop hosts below 5% CPU from the chart",
|
||||
"The four threshold functions take one number. cutOffMin and cutOffMax remove the points below or above the threshold, so an idle host disappears from the chart instead of drawing a line along the bottom. clampMin and clampMax keep the points and replace the value with the threshold, which caps spikes without hiding them. system.cpu.utilization is a ratio, so 0.05 is 5%.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "avg", "spaceAggregation": "avg"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cutOffMin", 0.05)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_runningdiff_disk_growth",
|
||||
"runningDiff then clampMin: bytes written to disk per step",
|
||||
"runningDiff turns a gauge into the change between consecutive points, so a filesystem usage gauge becomes bytes written per step. It takes no arguments and drops the first point of the window, since that point has nothing to difference against; the server reads one step before the window to keep the first interval. Functions apply in order: clampMin with 0 runs on the differences and replaces the negative value a cleanup or a volume reset produces with zero.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.filesystem.usage", "timeAggregation": "latest", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "state = 'used'"},
|
||||
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("runningDiff"), function("clampMin", 0)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_ewma_smoothed_latency",
|
||||
"ewma5: smoothed p95 latency",
|
||||
"ewma3, ewma5 and ewma7 replace each point with an exponentially weighted average of the point and the points before it. The number in the name is the window the default weight is derived from: alpha = 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides alpha; a value close to 1 follows the raw series, a value close to 0 smooths harder. 0.3 here smooths a little more than ewma5's default.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p95"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("ewma5", 0.3)},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_median_despiked_request_rate",
|
||||
"median5: request rate without one-step spikes",
|
||||
"median3, median5 and median7 replace each point with the median of the surrounding 3, 5 or 7 points. A spike that lasts one step vanishes, a shift that lasts longer than half the window stays. The first and last half window, 2 points each for median5, have no full window and keep their values, so the newest spike stays visible; a series shorter than the window is returned unchanged. They take no arguments. Use them when the raw series is noisy and the chart is for a trend, not for catching the spike.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("median5")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_cumulativesum_errors_so_far",
|
||||
"cumulativeSum: 5xx responses since the start of the window",
|
||||
"cumulativeSum replaces each point with the sum of every point up to and including it, so a per-step count becomes a running total whose first point is the first count. It takes no arguments. The total resets with the window, so a longer time range gives a larger final value; it is a view of the window, not a counter.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": []any{map[string]any{"expression": "count()", "alias": "server_errors"}},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "http.response.status_code >= 500"},
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{function("cumulativeSum")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_log10_network_bytes",
|
||||
"log10: network throughput across devices that differ by orders of magnitude",
|
||||
"log2 and log10 replace each value with its logarithm, so a loopback device moving kilobytes and an uplink moving gigabytes fit on one chart. They take no arguments. Zero and negative values have no logarithm and are not drawn. absolute, also without arguments, is the usual companion for a signed series before a logarithm.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "system.network.io", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "60s",
|
||||
"filter": map[string]any{"expression": "direction = 'transmit'"},
|
||||
"groupBy": []any{map[string]any{"name": "device", "fieldContext": "attribute"}},
|
||||
"functions": []any{function("log10")},
|
||||
}),
|
||||
),
|
||||
timeSeriesExample(
|
||||
"function_anomaly_request_rate",
|
||||
"anomaly: request rate scored against its daily pattern",
|
||||
"anomaly is the one function with named arguments. seasonality says which past periods predict the current value: hourly, daily or weekly, daily when absent. z_score_threshold is how many standard deviations from the prediction count as an anomaly. It has no default: a request without it gets bounds of zero width, so send it. 3 is the value the UI sends. The response carries, next to the values, a predicted series, an upper and a lower bound series and an anomaly score series. The scoring runs in the enterprise build; the community build returns the series unchanged.",
|
||||
builderQueryEnvelope(map[string]any{
|
||||
"name": "A",
|
||||
"signal": "metrics",
|
||||
"aggregations": []any{
|
||||
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
|
||||
},
|
||||
"stepInterval": "300s",
|
||||
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
|
||||
"functions": []any{
|
||||
map[string]any{
|
||||
"name": "anomaly",
|
||||
"args": []any{
|
||||
map[string]any{"name": "seasonality", "value": "daily"},
|
||||
map[string]any{"name": "z_score_threshold", "value": 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
23
pkg/apiserver/signozapiserver/querier_examples_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
func TestQueryRangeFunctionExamplesAreValidRequests(t *testing.T) {
|
||||
for _, example := range queryRangeFunctionExamples() {
|
||||
t.Run(example.Name, func(t *testing.T) {
|
||||
raw, err := json.Marshal(example.Value)
|
||||
require.NoError(t, err)
|
||||
var req qbtypes.QueryRangeRequest
|
||||
require.NoError(t, json.Unmarshal(raw, &req))
|
||||
assert.NoError(t, req.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import (
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounRequestTypes = "request types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -88,6 +88,13 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// A limit ranks the groups over the statement window, so the pieces of a
|
||||
// window rank differently from the window. Such a query is never served
|
||||
// from the cache.
|
||||
if (q.spec.Signal == telemetrytypes.SignalLogs || q.spec.Signal == telemetrytypes.SignalTraces) && len(q.spec.GroupBy) > 0 && q.spec.Limit > 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
@@ -425,6 +425,12 @@ func (q *querier) processTimeSeriesFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create result for formula
|
||||
formulaResult := &qbtypes.TimeSeriesData{
|
||||
QueryName: formula.Name,
|
||||
@@ -660,6 +666,12 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
formulaSeries, err = applyFormulaHaving(formula, formulaSeries)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(ctx, "failed to apply formula having", errors.Attr(err), slog.String("formula", formula.Name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit, formula.Offset)
|
||||
|
||||
@@ -710,6 +722,35 @@ func (q *querier) processScalarFormula(
|
||||
}
|
||||
}
|
||||
|
||||
// applyFormulaHaving keeps the values the formula having accepts, as SQL HAVING
|
||||
// keeps rows for a builder query, so a series with no values left is removed. It
|
||||
// runs before functions, order, limit, and offset, as HAVING runs before them in SQL.
|
||||
func applyFormulaHaving(formula qbtypes.QueryBuilderFormula, series []*qbtypes.TimeSeries) ([]*qbtypes.TimeSeries, error) {
|
||||
if formula.Having == nil || strings.TrimSpace(formula.Having.Expression) == "" {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
keep, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kept := series[:0]
|
||||
for _, s := range series {
|
||||
values := s.Values[:0]
|
||||
for _, value := range s.Values {
|
||||
if keep(value.Value) {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
s.Values = values
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// filterDisabledQueries removes results for disabled queries.
|
||||
func (q *querier) filterDisabledQueries(results map[string]*qbtypes.Result, req *qbtypes.QueryRangeRequest) map[string]*qbtypes.Result {
|
||||
filtered := make(map[string]*qbtypes.Result)
|
||||
|
||||
@@ -212,6 +212,14 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
|
||||
switch query.Type {
|
||||
case qbtypes.QueryTypeFormula:
|
||||
// the querier evaluates a formula after its queries run, so a bad
|
||||
// having must fail here, before they run
|
||||
if formula, ok := query.Spec.(qbtypes.QueryBuilderFormula); ok && formula.Having != nil && strings.TrimSpace(formula.Having.Expression) != "" {
|
||||
if _, err := querybuilder.CompileFormulaHaving(formula.Name, formula.Having.Expression); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
case qbtypes.QueryTypePromQL:
|
||||
promQuery, ok := query.Spec.(qbtypes.PromQuery)
|
||||
if !ok {
|
||||
@@ -922,32 +930,25 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
copy(argsCopy, qt.args)
|
||||
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
|
||||
|
||||
// A builder query's window was moved into the shifted clock when it was
|
||||
// built, so a range taken from that window must not be shifted again.
|
||||
case *builderQuery[qbtypes.TraceAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
// reuse the original query's statement builder and type so an AI query
|
||||
// keeps its AI builder and cache key
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
shiftStmtBuilder := q.logStmtBuilder
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
|
||||
166
pkg/querybuilder/formula_having.go
Normal file
166
pkg/querybuilder/formula_having.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/havingexpression/grammar"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
)
|
||||
|
||||
// CompileFormulaHaving turns the having expression of a formula into a filter on
|
||||
// its values. The querier computes a formula in Go after ClickHouse returns, so the
|
||||
// having cannot run as SQL; it gets the same validation and messages as a
|
||||
// builder-query having, with the formula name and __result as the only references.
|
||||
func CompileFormulaHaving(formulaName, expression string) (func(value float64) bool, error) {
|
||||
rewriter := &HavingExpressionRewriter{columnMap: map[string]string{
|
||||
formulaName: qbtypes.DefaultOrderByKey,
|
||||
qbtypes.DefaultOrderByKey: qbtypes.DefaultOrderByKey,
|
||||
}}
|
||||
_, tree, err := rewriter.parseAndValidate(expression)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compileOrExpression(tree.Expression().OrExpression()), nil
|
||||
}
|
||||
|
||||
func compileOrExpression(ctx grammar.IOrExpressionContext) func(float64) bool {
|
||||
andExpressions := ctx.AllAndExpression()
|
||||
conditions := make([]func(float64) bool, len(andExpressions))
|
||||
for i, andExpression := range andExpressions {
|
||||
conditions[i] = compileAndExpression(andExpression)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if condition(value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compileAndExpression(ctx grammar.IAndExpressionContext) func(float64) bool {
|
||||
primaries := ctx.AllPrimary()
|
||||
conditions := make([]func(float64) bool, len(primaries))
|
||||
for i, primary := range primaries {
|
||||
conditions[i] = compilePrimary(primary)
|
||||
}
|
||||
|
||||
return func(value float64) bool {
|
||||
for _, condition := range conditions {
|
||||
if !condition(value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func compilePrimary(ctx grammar.IPrimaryContext) func(float64) bool {
|
||||
var condition func(float64) bool
|
||||
if ctx.OrExpression() != nil {
|
||||
condition = compileOrExpression(ctx.OrExpression())
|
||||
} else {
|
||||
condition = compileComparison(ctx.Comparison())
|
||||
}
|
||||
|
||||
if ctx.NOT() == nil {
|
||||
return condition
|
||||
}
|
||||
return func(value float64) bool { return !condition(value) }
|
||||
}
|
||||
|
||||
func compileComparison(ctx grammar.IComparisonContext) func(float64) bool {
|
||||
left := compileOperand(ctx.Operand(0))
|
||||
|
||||
if ctx.IN() != nil {
|
||||
signedNumbers := ctx.InList().AllSignedNumber()
|
||||
list := make([]float64, len(signedNumbers))
|
||||
for i, signedNumber := range signedNumbers {
|
||||
list[i] = parseHavingNumber(signedNumber.GetText())
|
||||
}
|
||||
notIn := ctx.NOT() != nil
|
||||
return func(value float64) bool { return slices.Contains(list, left(value)) != notIn }
|
||||
}
|
||||
|
||||
right := compileOperand(ctx.Operand(1))
|
||||
compOp := ctx.CompOp()
|
||||
switch {
|
||||
case compOp.EQUALS() != nil:
|
||||
return func(value float64) bool { return left(value) == right(value) }
|
||||
case compOp.NOT_EQUALS() != nil, compOp.NEQ() != nil:
|
||||
return func(value float64) bool { return left(value) != right(value) }
|
||||
case compOp.LT() != nil:
|
||||
return func(value float64) bool { return left(value) < right(value) }
|
||||
case compOp.LE() != nil:
|
||||
return func(value float64) bool { return left(value) <= right(value) }
|
||||
case compOp.GT() != nil:
|
||||
return func(value float64) bool { return left(value) > right(value) }
|
||||
default:
|
||||
return func(value float64) bool { return left(value) >= right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileOperand(ctx grammar.IOperandContext) func(float64) float64 {
|
||||
if ctx.Operand() == nil {
|
||||
return compileTerm(ctx.Term())
|
||||
}
|
||||
|
||||
left := compileOperand(ctx.Operand())
|
||||
right := compileTerm(ctx.Term())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return left(value) - right(value) }
|
||||
}
|
||||
return func(value float64) float64 { return left(value) + right(value) }
|
||||
}
|
||||
|
||||
func compileTerm(ctx grammar.ITermContext) func(float64) float64 {
|
||||
if ctx.Term() == nil {
|
||||
return compileFactor(ctx.Factor())
|
||||
}
|
||||
|
||||
left := compileTerm(ctx.Term())
|
||||
right := compileFactor(ctx.Factor())
|
||||
switch {
|
||||
case ctx.SLASH() != nil:
|
||||
return func(value float64) float64 { return left(value) / right(value) }
|
||||
case ctx.PERCENT() != nil:
|
||||
return func(value float64) float64 { return math.Mod(left(value), right(value)) }
|
||||
default:
|
||||
return func(value float64) float64 { return left(value) * right(value) }
|
||||
}
|
||||
}
|
||||
|
||||
func compileFactor(ctx grammar.IFactorContext) func(float64) float64 {
|
||||
if ctx.Factor() != nil {
|
||||
inner := compileFactor(ctx.Factor())
|
||||
if ctx.MINUS() != nil {
|
||||
return func(value float64) float64 { return -inner(value) }
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
if ctx.Operand() != nil {
|
||||
return compileOperand(ctx.Operand())
|
||||
}
|
||||
|
||||
if number := ctx.Atom().NUMBER(); number != nil {
|
||||
constant := parseHavingNumber(number.GetText())
|
||||
return func(float64) float64 { return constant }
|
||||
}
|
||||
|
||||
// validation rejects function calls, strings, and unknown names, so the atom
|
||||
// names the formula value
|
||||
return func(value float64) float64 { return value }
|
||||
}
|
||||
|
||||
// ParseFloat reads every form the lexer admits as a number; a literal outside the
|
||||
// float64 range becomes the nearest value ParseFloat returns.
|
||||
func parseHavingNumber(text string) float64 {
|
||||
number, _ := strconv.ParseFloat(text, 64)
|
||||
return number
|
||||
}
|
||||
86
pkg/querybuilder/formula_having_test.go
Normal file
86
pkg/querybuilder/formula_having_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
func TestCompileFormulaHaving_Matches(t *testing.T) {
|
||||
values := []float64{-2, 0, 1, 2.5, 3, 10}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
want []float64
|
||||
}{
|
||||
{name: "Name_GreaterThan", expression: "F1 > 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Result_LessOrEqual", expression: "__result <= 1", want: []float64{-2, 0, 1}},
|
||||
{name: "DoubleEquals", expression: "F1 == 3", want: []float64{3}},
|
||||
{name: "AngleNotEquals", expression: "F1 <> 0", want: []float64{-2, 1, 2.5, 3, 10}},
|
||||
{name: "In_Parentheses", expression: "F1 IN (0, 3, -2)", want: []float64{-2, 0, 3}},
|
||||
{name: "NotIn_Brackets", expression: "F1 NOT IN [0, 3, -2]", want: []float64{1, 2.5, 10}},
|
||||
{name: "AndBindsTighterThanOr", expression: "F1 > 0 AND F1 < 3 OR F1 = -2", want: []float64{-2, 1, 2.5}},
|
||||
{name: "ImplicitAnd", expression: "F1 > 0 F1 < 3", want: []float64{1, 2.5}},
|
||||
{name: "LowercaseKeywords", expression: "F1 > 2 and not F1 = 10", want: []float64{2.5, 3}},
|
||||
{name: "NotParenthesized", expression: "NOT (F1 >= 1 AND F1 <= 3)", want: []float64{-2, 0, 10}},
|
||||
{name: "Arithmetic", expression: "F1 * 2 - 1 > 4", want: []float64{3, 10}},
|
||||
{name: "Arithmetic_Parentheses", expression: "(F1 + 2) / 2 >= 2", want: []float64{2.5, 3, 10}},
|
||||
{name: "Modulo", expression: "F1 % 2 = 1", want: []float64{1, 3}},
|
||||
{name: "UnaryMinus", expression: "-F1 > 1", want: []float64{-2}},
|
||||
{name: "Number_Exponent", expression: "F1 >= 1e1", want: []float64{10}},
|
||||
{name: "Number_LeadingDot", expression: "F1 < .5", want: []float64{-2, 0}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, keep)
|
||||
|
||||
got := []float64{}
|
||||
for _, value := range values {
|
||||
if keep(value) {
|
||||
got = append(got, value)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, testCase.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileFormulaHaving_Invalid(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expression string
|
||||
wantErrMsg string
|
||||
wantAdditional []string
|
||||
}{
|
||||
{
|
||||
name: "QueryName_NotAReference",
|
||||
expression: "A > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [A]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
{
|
||||
name: "Aggregation_NotAReference",
|
||||
expression: "count() > 0",
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [F1, __result]"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
keep, err := CompileFormulaHaving("F1", testCase.expression)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, keep)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
assert.ErrorContains(t, err, testCase.wantErrMsg)
|
||||
assert.Equal(t, testCase.wantAdditional, additionalMessages(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,13 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
return name
|
||||
}
|
||||
|
||||
// rewriteAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
rewritten, _, err := r.parseAndValidate(expression)
|
||||
return rewritten, err
|
||||
}
|
||||
|
||||
// parseAndValidate is the single-pass implementation used by all RewriteFor* methods.
|
||||
// It also returns the parse tree, which CompileFormulaHaving evaluates in Go.
|
||||
//
|
||||
// Validation layers:
|
||||
// 1. The visitor runs on the parse tree, rewriting and collecting invalid references.
|
||||
@@ -248,7 +254,7 @@ func (v *havingExpressionRewriteVisitor) visitIdentifier(ctx grammar.IIdentifier
|
||||
// 2. String literals in comparison-operand position → descriptive error
|
||||
// ("aggregator results are numeric").
|
||||
// 3. ANTLR syntax errors → error with messages referencing the original token names.
|
||||
func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string, error) {
|
||||
func (r *HavingExpressionRewriter) parseAndValidate(expression string) (string, grammar.IQueryContext, error) {
|
||||
original := strings.TrimSpace(expression)
|
||||
|
||||
// Parse the expression once.
|
||||
@@ -281,7 +287,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// This is checked before invalid references so that "contains string literals" takes
|
||||
// priority when a bare string literal is also an unresolvable operand.
|
||||
if v.hasStringLiteral {
|
||||
return "", errors.NewInvalidInputf(
|
||||
return "", nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"`Having` expression contains string literals",
|
||||
).WithAdditional("Aggregator results are numeric")
|
||||
@@ -317,7 +323,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
).WithAdditional(
|
||||
"Valid references are: [" + strings.Join(validKeys, ", ") + "]",
|
||||
).WithSuggestions(suggestions...)
|
||||
return "", havingErr
|
||||
return "", nil, havingErr
|
||||
}
|
||||
|
||||
// Layer 3 – ANTLR syntax errors. We parse the original expression, so error messages
|
||||
@@ -343,12 +349,12 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
return "", havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
return "", nil, havingErr.WithSuggestiveAdditional(msgs[0], suggestions...)
|
||||
}
|
||||
return "", havingErr.WithAdditional(msgs...)
|
||||
return "", nil, havingErr.WithAdditional(msgs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result, tree, nil
|
||||
}
|
||||
|
||||
// havingSuggestion returns a corrected expression string to show as a suggestion when
|
||||
|
||||
@@ -28,7 +28,7 @@ type QueryBuilderFormula struct {
|
||||
|
||||
Offset int `json:"offset,omitempty" description:"The number of ordered series or rows to skip before the page starts. Requires limit."`
|
||||
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
Having *Having `json:"having,omitempty" description:"A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match."`
|
||||
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
|
||||
@@ -118,6 +118,10 @@ func (f QueryBuilderFormula) Validate() error {
|
||||
)
|
||||
}
|
||||
|
||||
if f.Limit < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be non-negative, got %d", f.Limit)
|
||||
}
|
||||
|
||||
// Validate functions if present
|
||||
for i, fn := range f.Functions {
|
||||
if err := fn.Validate(); err != nil {
|
||||
|
||||
@@ -23,7 +23,8 @@ func (r *RequestType) UnmarshalJSON(data []byte) error {
|
||||
*r = v
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q; allowed values: %s", s, "`scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `distribution`, `heatmap`")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown request type %q", s).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,18 @@ func (q *QueryBuilderQuery[T]) validateOrderBy(cfg validationConfig) error {
|
||||
return q.validateOrderByForAggregation()
|
||||
}
|
||||
|
||||
// a raw order key resolves like a select field, so only an empty one is
|
||||
// invalid before resolution
|
||||
for i, order := range q.Order {
|
||||
if order.Key.Name == "" {
|
||||
orderID := fmt.Sprintf("order by clause #%d", i+1)
|
||||
if q.Name != "" {
|
||||
orderID = fmt.Sprintf("order by clause #%d in query '%s'", i+1, q.Name)
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid empty key name for %s", orderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -586,19 +598,10 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
default:
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"invalid request type: %s",
|
||||
r.RequestType,
|
||||
).WithAdditional(
|
||||
"Valid request types are: raw, timeseries, scalar, heatmap, distribution",
|
||||
)
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return err
|
||||
}
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return err
|
||||
@@ -639,14 +642,10 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap, RequestTypeDistribution:
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap, distribution")
|
||||
if err := r.validateRequestType(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := GetValidationOptions(r.RequestType)
|
||||
|
||||
if err := r.validateBucketCounting(); err != nil {
|
||||
return nil, err
|
||||
@@ -703,6 +702,20 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
// Enum is the one list of request types a query can use, so the spec and the
|
||||
// suggestions agree with what is accepted.
|
||||
func (r *QueryRangeRequest) validateRequestType() error {
|
||||
if slices.Contains(RequestType{}.Enum(), any(r.RequestType)) {
|
||||
return nil
|
||||
}
|
||||
if r.RequestType == RequestTypeUnknown {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "requestType is required").
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request type %q is not supported", r.RequestType.StringValue()).
|
||||
WithSuggestions(errors.NewValidReferences(errors.NounRequestTypes, RequestType{}.Enum()...))
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange() error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -521,6 +527,28 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "expression cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "Formula_NegativeLimit_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeTimeSeries,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeFormula,
|
||||
Spec: QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A * 2",
|
||||
Limit: -5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid formula 'F1': limit must be non-negative, got -5",
|
||||
},
|
||||
{
|
||||
name: "promql with empty query should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -696,6 +724,31 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
|
||||
wantErr: true,
|
||||
errMsg: "raw request type is not supported for metric queries",
|
||||
},
|
||||
{
|
||||
name: "Raw_EmptyOrderKey_Rejected",
|
||||
request: QueryRangeRequest{
|
||||
Start: 1640995200000,
|
||||
End: 1640998800000,
|
||||
RequestType: RequestTypeRaw,
|
||||
CompositeQuery: CompositeQuery{
|
||||
Queries: []QueryEnvelope{
|
||||
{
|
||||
Type: QueryTypeBuilder,
|
||||
Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Order: []OrderBy{
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp"}}, Direction: OrderDirectionDesc},
|
||||
{Key: OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: ""}}, Direction: OrderDirectionAsc},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid query 'B': invalid empty key name for order by clause #2 in query 'B'",
|
||||
},
|
||||
{
|
||||
name: "timeseries request with group by timestamp should return error",
|
||||
request: QueryRangeRequest{
|
||||
@@ -1407,6 +1460,44 @@ func TestRequestType_IsAggregation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRangeRequest_ValidateRequestType(t *testing.T) {
|
||||
validRequestTypes := []string{"valid request types are `scalar`, `time_series`, `raw`, `raw_stream`, `trace`, `heatmap`, `distribution`"}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "Missing_Required",
|
||||
requestType: "",
|
||||
message: "requestType is required",
|
||||
},
|
||||
{
|
||||
name: "Timeseries_Unknown",
|
||||
requestType: `"requestType": "timeseries",`,
|
||||
message: `unknown request type "timeseries"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"schemaVersion": "v1", "start": 1640995200000, "end": 1640998800000, %s "compositeQuery": {"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs"}}]}}`, testCase.requestType)
|
||||
|
||||
var request QueryRangeRequest
|
||||
err := json.Unmarshal([]byte(body), &request)
|
||||
if err == nil {
|
||||
err = request.Validate()
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
got := errors.AsJSON(err)
|
||||
assert.Equal(t, testCase.message, got.Message)
|
||||
assert.Equal(t, validRequestTypes, got.Suggestions)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
// Fields that only apply to aggregation queries (groupBy, having, aggregations)
|
||||
// should be silently skipped for non-aggregation request types.
|
||||
|
||||
3
tests/fixtures/querier.py
vendored
3
tests/fixtures/querier.py
vendored
@@ -363,6 +363,7 @@ def build_formula_query(
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
having_expression: str | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -379,6 +380,8 @@ def build_formula_query(
|
||||
spec["limit"] = limit
|
||||
if offset is not None:
|
||||
spec["offset"] = offset
|
||||
if having_expression:
|
||||
spec["having"] = {"expression": having_expression}
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
|
||||
86
tests/integration/tests/queriercommon/10_cache_time_shift.py
Normal file
86
tests/integration/tests/queriercommon/10_cache_time_shift.py
Normal file
@@ -0,0 +1,86 @@
|
||||
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.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_function,
|
||||
build_group_by_field,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_time_shift_partial_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
The hour before the shifted one holds different counts, so a gap read
|
||||
one shift too far back returns wrong points instead of nothing.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=now - 120 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 4), ("svc-b", 2))
|
||||
for i in range(count)
|
||||
]
|
||||
+ [
|
||||
Logs(
|
||||
timestamp=now - 180 * MINUTE + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} earlier {minute} {i}",
|
||||
)
|
||||
for minute in range(60)
|
||||
for service, count in (("svc-a", 9), ("svc-b", 7))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
functions=[build_function("timeShift", 3600)],
|
||||
)
|
||||
start_ms = int((now - 60 * MINUTE).timestamp() * 1000)
|
||||
first_end_ms = int((now - 30 * MINUTE).timestamp() * 1000)
|
||||
second_end_ms = int((now - 10 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, start_ms, first_end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, start_ms, second_end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-a", "svc-b"}, fresh_points
|
||||
assert {service: len(points) for service, points in fresh_points.items()} == {"svc-a": 50, "svc-b": 50}
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
@@ -0,0 +1,135 @@
|
||||
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.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
get_all_series,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
MINUTE = timedelta(minutes=1)
|
||||
|
||||
|
||||
def test_limited_group_by_partial_hit_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
The two halves have different winners, so a limit applied per cached
|
||||
piece returns the union of both instead of the window's top two.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
head_start = now - 60 * MINUTE
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=head_start + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(40)
|
||||
for service, count in (("svc-a", 10 if minute < 20 else 1), ("svc-b", 8 if minute < 20 else 1), ("svc-c", 1 if minute < 20 else 10), ("svc-d", 1 if minute < 20 else 8))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=2,
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
)
|
||||
head_start_ms = int(head_start.timestamp() * 1000)
|
||||
head_end_ms = int((head_start + 20 * MINUTE).timestamp() * 1000)
|
||||
tail_end_ms = int((head_start + 40 * MINUTE).timestamp() * 1000)
|
||||
|
||||
warm = make_query_request(signoz, token, head_start_ms, head_end_ms, [query], no_cache=False)
|
||||
assert warm.status_code == HTTPStatus.OK, warm.text
|
||||
cached = make_query_request(signoz, token, head_start_ms, tail_end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, head_start_ms, tail_end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-a", "svc-c"}, fresh_points
|
||||
assert {service: len(points) for service, points in fresh_points.items()} == {"svc-a": 40, "svc-c": 40}
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
|
||||
|
||||
def test_limited_group_by_shrunk_window_matches_no_cache(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
svc-a wins the full window and svc-b wins its second half, so a cache
|
||||
that keeps the window's winner serves the wrong series for the half.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
# the cache outlives the run, so a fixed filter would serve the previous
|
||||
# run's points back to this one
|
||||
run = uuid4().hex[:8]
|
||||
start = now - 40 * MINUTE
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=start + minute * MINUTE + timedelta(seconds=1 + i),
|
||||
resources={"service.name": service},
|
||||
attributes={"run": run},
|
||||
body=f"{service} {minute} {i}",
|
||||
)
|
||||
for minute in range(10)
|
||||
for service, count in (("svc-a", 20 if minute < 5 else 1), ("svc-b", 1 if minute < 5 else 10))
|
||||
for i in range(count)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
query = build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name", "string", "resource")],
|
||||
order=[build_order_by("count()", "desc")],
|
||||
limit=1,
|
||||
filter_expression=f"run = '{run}'",
|
||||
step_interval=60,
|
||||
)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
middle_ms = int((start + 5 * MINUTE).timestamp() * 1000)
|
||||
end_ms = int((start + 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, middle_ms, end_ms, [query], no_cache=False)
|
||||
assert cached.status_code == HTTPStatus.OK, cached.text
|
||||
fresh = make_query_request(signoz, token, middle_ms, end_ms, [query], no_cache=True)
|
||||
assert fresh.status_code == HTTPStatus.OK, fresh.text
|
||||
|
||||
fresh_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(fresh.json(), "A"), "service.name").items()}
|
||||
assert set(fresh_points) == {"svc-b"}, fresh_points
|
||||
assert sorted(fresh_points["svc-b"].values()) == [10] * 5, fresh_points
|
||||
|
||||
cached_points = {service: {point["timestamp"]: point["value"] for point in series["values"]} for service, series in index_series_by_label(get_all_series(cached.json(), "A"), "service.name").items()}
|
||||
assert cached_points == fresh_points
|
||||
@@ -2,6 +2,8 @@ from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
@@ -12,6 +14,9 @@ from fixtures.querier import (
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
get_all_series,
|
||||
get_error_message,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
@@ -191,3 +196,138 @@ def test_logs_formula_orderby_and_limit(
|
||||
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
|
||||
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
|
||||
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
|
||||
|
||||
|
||||
# Logs per service at 5, 4, 3, 2, and 1 minutes before now. Totals: a 15, b 17, c 5, d 15.
|
||||
FORMULA_HAVING_COUNTS = {
|
||||
"service-a": [1, 4, 2, 5, 3],
|
||||
"service-b": [6, 1, 1, 2, 7],
|
||||
"service-c": [1, 1, 1, 1, 1],
|
||||
"service-d": [3, 3, 3, 3, 3],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query_having,formula_having,expected_services",
|
||||
[
|
||||
pytest.param("count() > 4 AND count() < 16", "F1 > 4 AND F1 < 16", {"service-a", "service-b"}, id="and_range"),
|
||||
pytest.param("count() IN (1, 5, 17) OR count() > 15", "__result IN (1, 5, 17) OR __result > 15", {"service-a", "service-b", "service-c"}, id="in_or"),
|
||||
pytest.param("NOT (count() * 2 - 1 > 9)", "NOT (F1 * 2 - 1 > 9)", {"service-a", "service-b", "service-c", "service-d"}, id="not_arithmetic"),
|
||||
],
|
||||
)
|
||||
def test_logs_formula_having_time_series_matches_query_having(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
query_having: str,
|
||||
formula_having: str,
|
||||
expected_services: set[str],
|
||||
) -> None:
|
||||
"""
|
||||
The querier computes formulas in Go, so a formula having runs in Go. For the
|
||||
formula `A`, it must keep the same points that SQL HAVING keeps on query A.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs([Logs(timestamp=now - timedelta(minutes=5 - minute), resources={"service.name": service}, body=f"{service} log {minute}-{i}") for service, counts in FORMULA_HAVING_COUNTS.items() for minute, count in enumerate(counts) for i in range(count)])
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
group_by = [build_group_by_field("service.name", "string", "resource")]
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, disabled=True),
|
||||
build_scalar_query(name="B", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, having_expression=query_having),
|
||||
build_formula_query("F1", "A", having_expression=formula_having),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
query_series = index_series_by_label(get_all_series(response.json(), "B"), "service.name")
|
||||
formula_series = index_series_by_label(get_all_series(response.json(), "F1"), "service.name")
|
||||
assert set(formula_series) == expected_services
|
||||
assert set(query_series) == expected_services
|
||||
for service in expected_services:
|
||||
query_points = sorted((value["timestamp"], value["value"]) for value in query_series[service]["values"])
|
||||
formula_points = sorted((value["timestamp"], value["value"]) for value in formula_series[service]["values"])
|
||||
assert formula_points == query_points, service
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query_having,formula_having,expected_services",
|
||||
[
|
||||
pytest.param("count() > 4 AND count() < 16", "F1 > 4 AND F1 < 16", {"service-a", "service-c", "service-d"}, id="and_range"),
|
||||
pytest.param("count() IN (1, 5, 17) OR count() > 15", "__result IN (1, 5, 17) OR __result > 15", {"service-b", "service-c"}, id="in_or"),
|
||||
pytest.param("NOT (count() * 2 - 1 > 9)", "NOT (F1 * 2 - 1 > 9)", {"service-c"}, id="not_arithmetic"),
|
||||
],
|
||||
)
|
||||
def test_logs_formula_having_scalar_matches_query_having(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
query_having: str,
|
||||
formula_having: str,
|
||||
expected_services: set[str],
|
||||
) -> None:
|
||||
"""
|
||||
For the formula `A` in a scalar request, the formula having must keep the
|
||||
same rows that SQL HAVING keeps on query A.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_logs([Logs(timestamp=now - timedelta(minutes=5 - minute), resources={"service.name": service}, body=f"{service} log {minute}-{i}") for service, counts in FORMULA_HAVING_COUNTS.items() for minute, count in enumerate(counts) for i in range(count)])
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
group_by = [build_group_by_field("service.name", "string", "resource")]
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="scalar",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, disabled=True),
|
||||
build_scalar_query(name="B", signal="logs", aggregations=[build_aggregation("count()")], group_by=group_by, having_expression=query_having),
|
||||
build_formula_query("F1", "A", having_expression=formula_having),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
values_by_query = {}
|
||||
for name in ("B", "F1"):
|
||||
result = find_named_result(results, name)
|
||||
assert result is not None, name
|
||||
service_column = next(i for i, column in enumerate(result["columns"]) if column["name"] == "service.name")
|
||||
value_column = next(i for i, column in enumerate(result["columns"]) if column["columnType"] == "aggregation")
|
||||
values_by_query[name] = {row[service_column]: row[value_column] for row in result["data"]}
|
||||
assert set(values_by_query["F1"]) == expected_services
|
||||
assert values_by_query["F1"] == values_by_query["B"]
|
||||
|
||||
|
||||
def test_logs_formula_having_invalid_reference(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""A formula having can reference only the formula value, so `count()` is a bad request."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="time_series",
|
||||
queries=[
|
||||
build_scalar_query(name="A", signal="logs", aggregations=[build_aggregation("count()")], disabled=True),
|
||||
build_formula_query("F1", "A", having_expression="count() > 0"),
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert get_error_message(response.json()) == "Invalid references in `Having` expression: [count]"
|
||||
|
||||
@@ -128,6 +128,15 @@ request_types = pytest.mark.parametrize(
|
||||
"having is not supported for {request_type} requests",
|
||||
id="having_on_the_query",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
build_builder_query("A", METRIC_NAME, "max", "max", disabled=True),
|
||||
build_formula_query("F1", "A", having_expression="F1 > 1"),
|
||||
],
|
||||
{},
|
||||
"having is not supported for {request_type} requests",
|
||||
id="having_on_the_formula",
|
||||
),
|
||||
pytest.param(
|
||||
[build_builder_query("A", METRIC_NAME, "max", "max", bucket_options={"kind": "quadratic", "spec": {}})],
|
||||
{},
|
||||
|
||||
Reference in New Issue
Block a user