Compare commits

..

6 Commits

Author SHA1 Message Date
Naman Verma
62d477e920 Merge branch 'main' into nv/exec-stats 2026-11-09 12:20:27 +05:30
Naman Verma
fe45b9dcae Merge branch 'main' into nv/exec-stats 2026-11-06 00:51:46 +05:30
Naman Verma
63ee60a40c fix(querier): stop cache refreshes from inflating exec stats 2026-11-06 00:51:03 +05:30
Naman Verma
29a393e47e Merge branch 'main' into nv/exec-stats 2026-11-05 10:51:11 +05:30
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
48 changed files with 348 additions and 1206 deletions

9
.github/CODEOWNERS vendored
View File

@@ -209,10 +209,6 @@ 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
@@ -221,11 +217,6 @@ 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

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -95,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.25
GO_VERSION: 1.24
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.25
GO_VERSION: 1.24
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.24"
# copy the caches from build
- name: get-sha

View File

@@ -11693,20 +11693,12 @@ components:
type: string
nullable: true
type: array
includeSamples:
description: Whether notifications include sample data from the alert window.
Not read yet. Defaults to false.
type: boolean
newGroupEvalDelay:
description: A grace period, as a Go duration such as 2m, during which a
newly seen label group is excluded from evaluation. Applies to metric
rules only; log and trace rules are never delayed. A group whose values
are not yet known is evaluated immediately.
type: string
notificationBody:
description: The notification body template, read when useTemplates is true.
Channels fall back to their own body when it is empty.
type: string
renotify:
allOf:
- $ref: '#/components/schemas/RuletypesRenotify'
@@ -11717,12 +11709,6 @@ components:
policies instead of the channels named on the thresholds. Defaults to
false.
type: boolean
useTemplates:
description: When true, the alert name is the notification title and notificationBody
the body, both rendered as templates with variables such as $labels.service.name
and $alert.value. When false, channels use their own title and body. Defaults
to false.
type: boolean
type: object
RuletypesPanelType:
enum:

View File

@@ -277,7 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -132,97 +132,10 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
assert.Equal(t, c.expectAlerts, alertsFound)
for _, item := range rule.Active {
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue))
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue))
}
})
}
}
func TestAnomalyRule_ValueAndThresholdAnnotations(t *testing.T) {
baseTime := time.Unix(1700000000, 0)
evalTime := baseTime.Add(5 * time.Minute)
target := 2.0
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Test anomaly value"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &target,
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
},
}},
},
SelectedQuery: "A",
Seasonality: ruletypes.SeasonalityDaily,
Thresholds: &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{{
Name: "Test anomaly value",
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsAbove,
}},
},
},
Annotations: map[string]string{
"summary": "{{$value}}",
"description": "{{$threshold}}",
},
}
response := &anomaly.AnomaliesResponse{
Results: []*qbtypes.TimeSeriesData{
{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
AnomalyScores: []*qbtypes.TimeSeries{{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "service.name"}, Value: "checkout"}},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: baseTime.Add(1 * time.Minute).UnixMilli(), Value: 1.5},
{Timestamp: baseTime.Add(2 * time.Minute).UnixMilli(), Value: 3.5},
},
}},
}},
},
},
}
rule, err := NewAnomalyRule("test-anomaly-rule", valuer.GenerateUUID(), &postableRule, nil, instrumentationtest.New().Logger(), mustParseURL(t, "http://localhost:8000"))
require.NoError(t, err)
rule.provider = &mockAnomalyProvider{responses: []*anomaly.AnomaliesResponse{response}}
alertsFound, err := rule.Eval(context.Background(), evalTime)
require.NoError(t, err)
require.Equal(t, 1, alertsFound)
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value)
assert.Equal(t, item.Annotations.Get("summary"), value)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold)
assert.Equal(t, item.Annotations.Get("description"), threshold)
}
}
func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// Test missing data alert with AbsentFor grace period
// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints

View File

@@ -12891,21 +12891,11 @@ export interface RuletypesNotificationSettingsDTO {
* @description Label names whose values group alerts into one notification, such as service.name.
*/
groupBy?: string[] | null;
/**
* @type boolean
* @description Whether notifications include sample data from the alert window. Not read yet. Defaults to false.
*/
includeSamples?: boolean;
/**
* @type string
* @description A grace period, as a Go duration such as 2m, during which a newly seen label group is excluded from evaluation. Applies to metric rules only; log and trace rules are never delayed. A group whose values are not yet known is evaluated immediately.
*/
newGroupEvalDelay?: string;
/**
* @type string
* @description The notification body template, read when useTemplates is true. Channels fall back to their own body when it is empty.
*/
notificationBody?: string;
/**
* @description Whether and how often to re-send a notification for an alert that stays active.
*/
@@ -12915,11 +12905,6 @@ export interface RuletypesNotificationSettingsDTO {
* @description When true, alerts are routed by the organisation level notification policies instead of the channels named on the thresholds. Defaults to false.
*/
usePolicy?: boolean;
/**
* @type boolean
* @description When true, the alert name is the notification title and notificationBody the body, both rendered as templates with variables such as $labels.service.name and $alert.value. When false, channels use their own title and body. Defaults to false.
*/
useTemplates?: boolean;
}
/**

View File

@@ -1,22 +1,24 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } 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,
@@ -26,19 +28,49 @@ 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 { rules: alertRules, isError, isLoading } = useHomeAlertRules();
const rulesExist = alertRules.length > 0;
const {
data: alerts,
isError,
isLoading,
} = useListRules({
query: { cacheTime: 0 },
});
useEffect(() => {
if (rulesExist && !loadingUserPreferences) {
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) {
onUpdateChecklistDoneItem('SETUP_ALERTS');
}
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
setSortedAlertRules(sortedRules.slice(0, 5));
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -63,7 +95,7 @@ export default function AlertRules({
className="periscope-btn secondary"
icon={<Plus size={16} />}
onClick={(): void => {
void logEvent('Homepage: Create alert rule clicked', {});
logEvent('Homepage: Create alert rule clicked', {});
}}
>
Create Alert Rule
@@ -74,7 +106,7 @@ export default function AlertRules({
type="link"
className="learn-more-link"
onClick={(): void => {
void logEvent('Homepage: Learn more clicked', {
logEvent('Homepage: Learn more clicked', {
source: 'Alert Rules',
});
@@ -93,13 +125,26 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
void logEvent('Homepage: Alert clicked', {
const onEditHandler = (record: RuletypesRuleDTO) => (): 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()}`);
@@ -108,12 +153,11 @@ export default function AlertRules({
const renderAlertRules = (): JSX.Element => (
<div className="alert-rules-container home-data-item-container">
<div className="alert-rules-list">
{alertRules.map((rule) => (
{sortedAlertRules.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 => {
@@ -195,7 +239,7 @@ export default function AlertRules({
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
void logEvent('Homepage: All alert rules clicked', {});
logEvent('Homepage: All alert rules clicked', {});
}}
>
All Alert Rules <ArrowRight size={12} />

View File

@@ -1,64 +0,0 @@
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,
};
}

View File

@@ -1,119 +0,0 @@
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',
]);
});
});

View File

@@ -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,7 +55,9 @@ export function PlannedDowntime(): JSX.Element {
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListAllRules({ cacheTime: 0 });
} = useListRules({
query: { cacheTime: 0 },
});
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
@@ -71,7 +73,7 @@ export function PlannedDowntime(): JSX.Element {
const downtimeSchedules = useListDowntimeSchedules();
const alertOptions = React.useMemo(
() =>
alertsData?.map((i) => ({
alertsData?.data?.map((i) => ({
label: i.alert,
value: i.id,
})),
@@ -179,7 +181,7 @@ export function PlannedDowntime(): JSX.Element {
};
const refetchAllSchedules = (): void => {
void downtimeSchedules.refetch();
downtimeSchedules.refetch();
};
const {

View File

@@ -4,7 +4,6 @@ import { fireEvent, screen } from '@testing-library/react';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertmanagertypesRepeatOnDTO,
@@ -127,19 +126,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
}));
let mockRulesState: {
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
data?: { data: Array<{ id: number | string; alert: string }> };
isError: boolean;
isLoading: boolean;
isFetching: boolean;
} = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
};
jest.mock('hooks/alerts/useListAllRules', () => ({
useListAllRules: (): typeof mockRulesState => mockRulesState,
jest.mock('api/generated/services/rules', () => ({
useListRules: (): typeof mockRulesState => mockRulesState,
}));
describe('PlannedDowntime Component', () => {
@@ -152,7 +151,7 @@ describe('PlannedDowntime Component', () => {
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: [],
data: { data: [] },
isError: false,
isLoading: false,
isFetching: false,
@@ -363,7 +362,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,
@@ -388,7 +387,14 @@ describe('PlannedDowntime Component', () => {
it('shows resolved alert rule name when scoped rule ID exists', async () => {
mockRulesState = {
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
data: {
data: [
{
id: 'rule-42',
alert: 'Disk Space High',
} as any,
],
},
isError: false,
isLoading: false,
isFetching: false,

View File

@@ -1,40 +0,0 @@
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']);
});
});

View File

@@ -1,40 +0,0 @@
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,
});
}

View File

@@ -17,8 +17,10 @@ import {
FIRST_DOWNTIME_NAME,
} from './__story_mockdata__/plannedDowntime';
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
import {
alertRulesResponse,
RULE_MAX,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Planned downtime · list';
@@ -84,20 +86,18 @@ export const plannedDowntimeMocks = defineStoryMocks({
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
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,
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',
}),
),
),
),
);
}),
),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,

View File

@@ -3,7 +3,6 @@
* Do not hand-edit: regenerate instead.
*/
import { listRulesV3Response } from 'mocks-server/__mockdata__/alert_rules';
import { rest } from 'msw';
import {
@@ -157,13 +156,11 @@ export const homeMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v3/rules',
response.json((req) =>
listRulesV3Response(
buildAlertRules(values.alertRules),
req.url.searchParams,
),
),
'http://localhost/api/v2/rules',
response.json(() => ({
status: 'success',
data: buildAlertRules(values.alertRules),
})),
),
rest.get(

View File

@@ -59,7 +59,7 @@ type Email struct {
// layoutData is the value passed to the email.signoz.html layout
// template. It embeds NotificationTemplateData so templates can reference
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalRecovering`, `.Alert.TotalResolved`,
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
// Title and per-alert Bodies.
type layoutData struct {

View File

@@ -11,7 +11,6 @@ import (
"log/slog"
"net"
"net/http"
"net/textproto"
"net/url"
"os"
"strconv"
@@ -707,9 +706,7 @@ func TestEmailRejected(t *testing.T) {
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
var smtpErr *textproto.Error
require.ErrorAs(t, err, &smtpErr)
assert.Equal(t, 501, smtpErr.Code)
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
assert.True(t, retry)
require.NoError(t, srv.Shutdown(ctx))

View File

@@ -389,7 +389,7 @@ func TestPrepareContent(t *testing.T) {
| Metric | Value |
|--------|-------|
| **Current** | *$value* |
| **Threshold** | *$threshold* |
| **Threshold** | *$threshold.value* |
**Status:** $alert.status | **Severity:** $labels.severity`
titleTemplate := "[$alert.status] $rule.name — $labels.service"
@@ -401,10 +401,10 @@ func TestPrepareContent(t *testing.T) {
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
ruletypes.AnnotationValue: "100",
ruletypes.AnnotationThresholdValue: "200",
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "100",
"threshold.value": "200",
},
},
}
@@ -414,10 +414,10 @@ func TestPrepareContent(t *testing.T) {
StartsAt: time.Now().Add(-2 * time.Hour),
EndsAt: time.Now().Add(-time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
ruletypes.AnnotationValue: "50",
ruletypes.AnnotationThresholdValue: "200",
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "50",
"threshold.value": "200",
},
},
}

View File

@@ -205,29 +205,16 @@ func (at *templater) buildNotificationTemplateData(
alertDataSlice = append(alertDataSlice, ad)
}
// count the number of firing, recovering and resolved alerts
var firing, recovering, resolved int
var isTest bool
// count the number of firing and resolved alerts
var firing, resolved int
for _, ad := range alertDataSlice {
isTest = isTest || ad.Alert.IsTest
switch {
case ad.Alert.IsFiring:
if ad.Alert.IsFiring {
firing++
case ad.Alert.IsRecovering:
recovering++
case ad.Alert.IsResolved:
} else if ad.Alert.IsResolved {
resolved++
}
}
// firing wins over recovering, which wins over resolved
status := string(model.AlertResolved)
if firing > 0 {
status = string(model.AlertFiring)
} else if recovering > 0 {
status = ruletypes.StateRecovering.StringValue()
}
// build the group labels
gl := make(template.KV, len(groupLabels))
for k, v := range groupLabels {
@@ -237,12 +224,10 @@ func (at *templater) buildNotificationTemplateData(
// build the notification template data
return &alertmanagertypes.NotificationTemplateData{
Alert: alertmanagertypes.NotificationAlert{
Receiver: receiver,
Status: status,
TotalFiring: firing,
TotalRecovering: recovering,
TotalResolved: resolved,
IsTest: isTest,
Receiver: receiver,
Status: string(types.Alerts(alerts...).Status()),
TotalFiring: firing,
TotalResolved: resolved,
},
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
GroupLabels: gl,
@@ -267,39 +252,27 @@ func buildAlertData(a *types.Alert, receiver string) alertmanagertypes.AlertData
annotations[string(k)] = string(v)
}
// The alertmanager only knows firing and resolved; the rule manager flags a
// firing alert whose value is between the threshold and the recovery
// threshold.
status := string(a.Status())
if a.Status() == model.AlertFiring && annotations[ruletypes.AnnotationIsRecovering] == "true" {
status = ruletypes.StateRecovering.StringValue()
}
return alertmanagertypes.AlertData{
Alert: alertmanagertypes.AlertInfo{
Status: status,
Status: string(a.Status()),
Receiver: receiver,
Value: annotations[ruletypes.AnnotationValue],
StartsAt: a.StartsAt,
EndsAt: a.EndsAt,
GeneratorURL: a.GeneratorURL,
Fingerprint: a.Fingerprint().String(),
IsFiring: status == string(model.AlertFiring),
IsResolved: status == string(model.AlertResolved),
IsFiring: a.Status() == model.AlertFiring,
IsResolved: a.Status() == model.AlertResolved,
IsMissingData: labels[ruletypes.LabelNoData] == "true",
IsRecovering: status == ruletypes.StateRecovering.StringValue(),
IsTest: labels[ruletypes.LabelTestAlert] == "true",
IsRecovering: labels[ruletypes.LabelIsRecovering] == "true",
},
Rule: buildRuleInfo(labels, annotations),
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
Exception: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedExceptions]},
Labels: labels,
Rule: buildRuleInfo(labels, annotations),
Log: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedLogs]},
Trace: alertmanagertypes.LinkInfo{URL: annotations[ruletypes.AnnotationRelatedTraces]},
Labels: labels,
// Strip private annotations once the structured fields above have
// been populated from the raw map.
Annotations: alertmanagertypes.FilterPublicAnnotations(annotations),
Value: annotations[ruletypes.AnnotationValue],
Threshold: annotations[ruletypes.AnnotationThresholdValue],
}
}
@@ -311,12 +284,10 @@ func buildRuleInfo(labels, annotations template.KV) alertmanagertypes.RuleInfo {
ID: labels[ruletypes.LabelRuleID],
URL: labels[ruletypes.LabelRuleSource],
Severity: labels[ruletypes.LabelSeverityName],
Type: annotations[ruletypes.AnnotationRuleType],
MatchType: annotations[ruletypes.AnnotationMatchType],
Threshold: alertmanagertypes.Threshold{
Value: annotations[ruletypes.AnnotationThresholdValue],
Op: annotations[ruletypes.AnnotationCompareOp],
RecoveryValue: annotations[ruletypes.AnnotationRecoveryThreshold],
Value: annotations[ruletypes.AnnotationThresholdValue],
Op: annotations[ruletypes.AnnotationCompareOp],
},
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/notify"
@@ -248,178 +247,6 @@ Description: Request rate exceeded 10k/s`},
wantBody: []string{"Runbook: https://runbook.example.com"},
wantIsDefaultBody: true,
},
{
name: "value annotation feeds alert.value and is hidden from annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
map[string]string{"summary": "errors are high", ruletypes.AnnotationValue: "523"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "Value: $alert.value, annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors",
wantBody: []string{"Value: 523, annotations: 1"},
},
{
name: "value shortcut resolves bare and in braces and wins over a label named value",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "value": "from-label"},
map[string]string{ruletypes.AnnotationValue: "523"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "$value | {{ $value }} | {{ if $value }}set{{ end }} | $labels.value",
},
wantTitle: "HighErrors",
wantBody: []string{"523 | 523 | set | from-label"},
},
{
name: "threshold shortcut and rule.threshold.value read the threshold annotation and win over a label named threshold",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "threshold": "from-label"},
map[string]string{ruletypes.AnnotationThresholdValue: "500"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name above $rule.threshold.value",
BodyTemplate: "$threshold | {{ $threshold }} | $rule.threshold.value | $labels.threshold | annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors above 500",
wantBody: []string{"500 | 500 | 500 | from-label | annotations: 0"},
},
{
name: "rule threshold op and recovery value read their private annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors"},
map[string]string{ruletypes.AnnotationThresholdValue: "500", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name $rule.threshold.op $rule.threshold.value",
BodyTemplate: "$rule.threshold.op $rule.threshold.value (recovers at $rule.threshold.recovery_value), annotations: {{ len $annotations }}",
},
wantTitle: "HighErrors above 500",
wantBody: []string{"above 500 (recovers at 400), annotations: 0"},
},
{
name: "rule type and match type read their private annotations",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "type": "from-label"},
map[string]string{ruletypes.AnnotationRuleType: "logs", ruletypes.AnnotationMatchType: "on_average"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.type: $rule.name",
BodyTemplate: "$rule.type $rule.match_type | $type | annotations: {{ len $annotations }}",
},
wantTitle: "logs: HighErrors",
wantBody: []string{"logs on_average | from-label | annotations: 0"},
},
{
name: "test label sets is_test on the title and each alert",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", ruletypes.LabelTestAlert: "true"},
nil,
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
BodyTemplate: "is_test=$alert.is_test",
},
wantTitle: "[TEST] HighErrors",
wantBody: []string{"is_test=true"},
},
{
name: "alert without test label is not a test",
alerts: []*types.Alert{
createAlert(map[string]string{ruletypes.LabelAlertName: "HighErrors"}, nil, true),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "{{ if $alert.is_test }}[TEST] {{ end }}$rule.name",
BodyTemplate: "is_test=$alert.is_test",
},
wantTitle: "HighErrors",
wantBody: []string{"is_test=false"},
},
{
name: "exception url reads its private annotation",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "CheckoutExceptions"},
map[string]string{ruletypes.AnnotationRelatedExceptions: "https://signoz.example/exceptions?serviceName=checkout"},
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$rule.name",
BodyTemplate: "[View exceptions]($exception.url), annotations: {{ len $annotations }}",
},
wantTitle: "CheckoutExceptions",
wantBody: []string{"[View exceptions](https://signoz.example/exceptions?serviceName=checkout), annotations: 0"},
},
{
name: "recovering annotation sets status, flags and counts with firing winning the title",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
true,
),
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
nil,
true,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
BodyTemplate: "$labels.team: $alert.status firing=$alert.is_firing recovering=$alert.is_recovering resolved=$alert.is_resolved, annotations: {{ len $annotations }}",
},
wantTitle: "firing: 1 firing, 1 recovering, 0 resolved",
wantBody: []string{
"core: recovering firing=false recovering=true resolved=false, annotations: 0",
"edge: firing firing=true recovering=false resolved=false, annotations: 0",
},
},
{
name: "recovering wins the title over resolved and a resolved alert ignores a stale recovering flag",
alerts: []*types.Alert{
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "core"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
true,
),
createAlert(
map[string]string{ruletypes.LabelAlertName: "HighErrors", "team": "edge"},
map[string]string{ruletypes.AnnotationIsRecovering: "true"},
false,
),
},
input: alertmanagertypes.ExpandRequest{
TitleTemplate: "$alert.status: $alert.total_firing firing, $alert.total_recovering recovering, $alert.total_resolved resolved",
BodyTemplate: "$labels.team: $alert.status recovering=$alert.is_recovering",
},
wantTitle: "recovering: 0 firing, 1 recovering, 1 resolved",
wantBody: []string{
"core: recovering recovering=true",
"edge: resolved recovering=false",
},
},
{
name: "using non-existing function in template",
alerts: []*types.Alert{
@@ -458,61 +285,3 @@ Description: Request rate exceeded 10k/s`},
})
}
}
func TestExpandTemplateFunctions(t *testing.T) {
at, ctx := testSetup(t)
startsAt := time.Date(2026, time.November, 12, 5, 30, 0, 0, time.UTC)
alert := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency", ruletypes.LabelSeverityName: "critical", "env": "prod-eu", "host": "web-1.example.com"},
Annotations: model.LabelSet{"summary": " p99 is high "},
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
}}
testCases := []struct {
name string
body string
wantBody string
}{
{name: "ToUpper", body: "{{ $env | toUpper }}", wantBody: "PROD-EU"},
{name: "ToLower", body: "{{ $env | toLower }}", wantBody: "prod-eu"},
{name: "Title", body: "{{ $rule.severity | title }}", wantBody: "Critical"},
{name: "TrimSpace", body: "[{{ $annotations.summary | trimSpace }}]", wantBody: "[p99 is high]"},
{name: "Match", body: `{{ if $env | match "^prod" }}production{{ end }}`, wantBody: "production"},
{name: "ReReplaceAll", body: `{{ $host | reReplaceAll "^([^.]+)\\..*" "$1" }}`, wantBody: "web-1"},
{name: "StringSlice_Join", body: `{{ stringSlice $env $host | join ", " }}`, wantBody: "prod-eu, web-1.example.com"},
{name: "Urlescape", body: `q={{ "a b&c" | urlescape }}`, wantBody: "q=a+b%26c"},
{name: "Date_AlertStartsAt", body: `{{ $alert.starts_at | date "Jan 2, 15:04 MST" }}`, wantBody: "Nov 12, 05:30 UTC"},
{name: "Tz_Date_AlertStartsAt", body: `{{ $alert.starts_at | tz "Asia/Kolkata" | date "15:04 MST" }}`, wantBody: "11:00 IST"},
{name: "Date_AlertEndsAt", body: `{{ $alert.ends_at | date "15:04" }}`, wantBody: "06:30"},
{name: "HumanizeDuration_Seconds", body: "{{ humanizeDuration 3725 }}", wantBody: "1h 2m 5s"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: testCase.body}, []*types.Alert{alert})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Equal(t, testCase.wantBody, result.Body[0])
})
}
t.Run("Now_Tz_Date", func(t *testing.T) {
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: `{{ now | tz "Asia/Kolkata" | date "2006 MST" }}`}, []*types.Alert{alert})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Equal(t, time.Now().In(time.FixedZone("IST", 5*60*60+30*60)).Format("2006 MST"), result.Body[0])
})
t.Run("Since_HumanizeDuration_AlertStartsAt", func(t *testing.T) {
recent := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighLatency"},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(time.Hour),
}}
result, err := at.Expand(ctx, alertmanagertypes.ExpandRequest{TitleTemplate: "$rule.name", BodyTemplate: "{{ $alert.starts_at | since | humanizeDuration }}"}, []*types.Alert{recent})
require.NoError(t, err)
require.Len(t, result.Body, 1)
assert.Regexp(t, `^1h 0m \d+s$`, result.Body[0])
})
}

View File

@@ -4,10 +4,10 @@ import (
"fmt"
"reflect"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/go-viper/mapstructure/v2"
)
// fieldPath is a dotted mapstructure path into the templating data map,
@@ -131,51 +131,16 @@ func structRootSet(data any) map[string]bool {
return roots
}
// toTemplateMap converts a tagged struct into nested maps keyed by its
// mapstructure tag names, the names templates reference. Unlike
// mapstructure.Decode it keeps time.Time values as is, so date, tz and since
// work on them. Fields that are untagged or tagged "-" are skipped.
func toTemplateMap(val reflect.Value) map[string]any {
typ := val.Type()
result := make(map[string]any, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if !field.IsExported() {
continue
}
name := strings.Split(field.Tag.Get("mapstructure"), ",")[0]
if name == "" || name == "-" {
continue
}
fv := val.Field(i)
if fv.Kind() == reflect.Pointer {
if fv.IsNil() {
result[name] = nil
continue
}
fv = fv.Elem()
}
if fv.Kind() == reflect.Struct && fv.Type() != reflect.TypeOf(time.Time{}) {
result[name] = toTemplateMap(fv)
continue
}
result[name] = fv.Interface()
}
return result
}
// buildDataMap converts the typed data struct to the map[string]any that the
// template engine indexes into. Each label and annotation is additionally
// exposed at the root under its raw key, so $service.name resolves a flat
// OTel-style label as a single-key index on the root. Struct-path keys
// already present at the root take precedence on collisions.
func buildDataMap(data any) (map[string]any, error) {
val := reflect.Indirect(reflect.ValueOf(data))
if val.Kind() != reflect.Struct {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to build template data map: expected a struct, got %T", data)
var result map[string]any
if err := mapstructure.Decode(data, &result); err != nil {
return nil, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to build template data map")
}
result := toTemplateMap(val)
flatten := func(labels, annotations map[string]string) {
for k, v := range labels {

View File

@@ -229,12 +229,10 @@ func TestPreProcessTemplateAndData(t *testing.T) {
},
expectedData: map[string]any{
"alert": map[string]any{
"receiver": "pagerduty",
"status": "firing",
"total_firing": 3,
"total_recovering": 0,
"total_resolved": 0,
"is_test": false,
"receiver": "pagerduty",
"status": "firing",
"total_firing": 3,
"total_resolved": 0,
},
"severity": "critical",
"http.request.method": "GET",

View File

@@ -1,7 +1,6 @@
package contextlinks
import (
"cmp"
"encoding/json"
"net/url"
"slices"
@@ -27,25 +26,6 @@ func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
}
// PrepareParamsForExceptions returns the exceptions page query params for the
// given range, filtered to the service and exception type found in labels
// (serviceName and exceptionType from SQL, or their builder keys). The page
// writes its time params in milliseconds.
// It doesn't reuse prepareExplorerParams: the exceptions page reads only the
// older filters.items from compositeQuery, not filter.expression.
func PrepareParamsForExceptions(start, end time.Time, labels map[string]string) url.Values {
params := url.Values{}
params.Set("startTime", strconv.FormatInt(start.UnixMilli(), 10))
params.Set("endTime", strconv.FormatInt(end.UnixMilli(), 10))
if serviceName := cmp.Or(labels["serviceName"], labels["service.name"]); serviceName != "" {
params.Set("serviceName", serviceName)
}
if exceptionType := cmp.Or(labels["exceptionType"], labels["exception.type"]); exceptionType != "" {
params.Set("exceptionType", exceptionType)
}
return params
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged

View File

@@ -2,7 +2,6 @@ package contextlinks
import (
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -223,30 +222,3 @@ func TestPrepareRuleFilterExpression(t *testing.T) {
})
}
}
func TestPrepareParamsForExceptions(t *testing.T) {
start := time.UnixMilli(1705468620000)
end := time.UnixMilli(1705468920000)
testCases := []struct {
name string
labels map[string]string
serviceName string
exceptionType string
}{
{name: "SQLColumns_SetsServiceAndExceptionType", labels: map[string]string{"serviceName": "checkout", "exceptionType": "TimeoutError"}, serviceName: "checkout", exceptionType: "TimeoutError"},
{name: "BuilderKeys_SetsServiceAndExceptionType", labels: map[string]string{"service.name": "payments", "exception.type": "ValueError"}, serviceName: "payments", exceptionType: "ValueError"},
{name: "NoMatchingLabels_SetsTimeOnly", labels: map[string]string{"host": "web-1"}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
params := PrepareParamsForExceptions(start, end, testCase.labels)
assert.Equal(t, "1705468620000", params.Get("startTime"))
assert.Equal(t, "1705468920000", params.Get("endTime"))
assert.Equal(t, testCase.serviceName, params.Get("serviceName"))
assert.Equal(t, testCase.exceptionType, params.Get("exceptionType"))
})
}
}

View File

@@ -26,8 +26,9 @@ const bucketCacheCellSteps = 60
type BucketCache interface {
// cached portion + list of gaps to fetch
GetMissRanges(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step) (cached *qbtypes.Result, missing []*qbtypes.TimeRange)
// store fresh buckets for future hits
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result)
// store fresh buckets for future hits; only the ranges in the map were
// queried for fresh, the rest of it came from the cache
Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats)
}
// bucketCache implements the BucketCache interface.
@@ -116,7 +117,7 @@ func (bc *bucketCache) GetMissRanges(
}
// Merge buckets into a single result
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings)
mergedResult := bc.mergeBuckets(ctx, relevantBuckets, cachedWarnings, startMs, endMs)
// Filter the merged result to only include values within the requested time range
mergedResult = bc.filterResultToTimeRange(mergedResult, q, startMs, endMs, stepMs)
@@ -125,7 +126,7 @@ func (bc *bucketCache) GetMissRanges(
}
// Put stores fresh query results in the cache.
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result) {
func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Query, step qbtypes.Step, fresh *qbtypes.Result, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) {
// Get query window
startMs, endMs := q.Window()
@@ -188,7 +189,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
// Convert trimmed result to buckets, one per grid cell it spans
cellWidthMs := bucketCacheCellSteps * stepMs
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs)
freshBuckets := bc.resultToBuckets(ctx, trimmedResult, cachableStartMs, cachableEndMs, cellWidthMs, execStatsForQueriedTimeRanges)
cellKeys := make([]string, len(freshBuckets))
cellEntries := make([]cachetypes.Cacheable, len(freshBuckets))
@@ -210,7 +211,7 @@ func (bc *bucketCache) Put(ctx context.Context, orgID valuer.UUID, q qbtypes.Que
cellEntry = cellEntries[i].(*qbtypes.CachedData)
}
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket)
cellBuckets, changed := bc.mergeFreshBucketIntoCell(ctx, cellEntry.Buckets, freshBucket, execStatsForQueriedTimeRanges)
if !changed {
continue
}
@@ -463,7 +464,7 @@ func (bc *bucketCache) filterRelevantBuckets(buckets []*qbtypes.CachedBucket, st
}
// mergeBuckets combines multiple cached buckets into a single result.
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string) *qbtypes.Result {
func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.CachedBucket, warnings []string, startMs, endMs uint64) *qbtypes.Result {
if len(buckets) == 0 {
return &qbtypes.Result{}
}
@@ -471,12 +472,13 @@ func (bc *bucketCache) mergeBuckets(ctx context.Context, buckets []*qbtypes.Cach
// All buckets should have the same type
resultType := buckets[0].Type
// Aggregate stats
// A bucket can reach past the window; only the part inside it is served
var totalStats qbtypes.ExecStats
for _, bucket := range buckets {
totalStats.RowsScanned += bucket.Stats.RowsScanned
totalStats.BytesScanned += bucket.Stats.BytesScanned
totalStats.DurationMS += bucket.Stats.DurationMS
servedStats := bucket.ProrateStatsToWindow(startMs, endMs)
totalStats.RowsScanned += servedStats.RowsScanned
totalStats.BytesScanned += servedStats.BytesScanned
totalStats.DurationMS += servedStats.DurationMS
}
// Merge values based on type
@@ -673,7 +675,7 @@ func (bc *bucketCache) isEmptyResult(result *qbtypes.Result) (isEmpty bool, isFi
}
// resultToBuckets converts a query result into time-based buckets.
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64) []*qbtypes.CachedBucket {
func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Result, startMs, endMs, cellWidthMs uint64, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) []*qbtypes.CachedBucket {
// Check if result is empty
isEmpty, isFiltered := bc.isEmptyResult(result)
@@ -685,7 +687,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
}
// Empty filtered results still become buckets, so the range is not re-queried
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs)
perCellBuckets, err := result.SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs, execStatsForQueriedTimeRanges)
if err != nil {
bc.logger.ErrorContext(ctx, "failed to convert result to buckets", errors.Attr(err))
return nil
@@ -697,7 +699,7 @@ func (bc *bucketCache) resultToBuckets(ctx context.Context, result *qbtypes.Resu
// and reports whether the cell changed: a bucket already covering the fresh
// one leaves it as is, buckets inside the fresh one are replaced, buckets
// overlapping or edge to edge with it are unioned, and the rest are kept apart.
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket) ([]*qbtypes.CachedBucket, bool) {
func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets []*qbtypes.CachedBucket, freshBucket *qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) ([]*qbtypes.CachedBucket, bool) {
keptBuckets := make([]*qbtypes.CachedBucket, 0, len(cellBuckets)+1)
touchingBuckets := []*qbtypes.CachedBucket{}
for _, cellBucket := range cellBuckets {
@@ -710,7 +712,7 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
keptBuckets = append(keptBuckets, cellBucket)
}
}
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets))
keptBuckets = append(keptBuckets, bc.unionBuckets(ctx, freshBucket, touchingBuckets, execStatsForQueriedTimeRanges))
slices.SortFunc(keptBuckets, func(a, b *qbtypes.CachedBucket) int {
return cmp.Compare(a.StartMs, b.StartMs)
@@ -721,7 +723,16 @@ func (bc *bucketCache) mergeFreshBucketIntoCell(ctx context.Context, cellBuckets
// unionBuckets widens the fresh bucket by the buckets it touches. Buckets
// inside it are replaced outright, so only a genuine partial overlap decodes
// anything.
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket) *qbtypes.CachedBucket {
func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.CachedBucket, touchingBuckets []*qbtypes.CachedBucket, execStatsForQueriedTimeRanges map[qbtypes.TimeRange]qbtypes.ExecStats) *qbtypes.CachedBucket {
// Data queried again takes the new query's cost, which the fresh bucket
// carries; a touching bucket keeps its cost only for what was not queried again
for _, touchingBucket := range touchingBuckets {
keptStats := touchingBucket.ProrateStatsToUnqueried(execStatsForQueriedTimeRanges)
freshBucket.Stats.RowsScanned += keptStats.RowsScanned
freshBucket.Stats.BytesScanned += keptStats.BytesScanned
freshBucket.Stats.DurationMS += keptStats.DurationMS
}
bucketsToMerge := []*qbtypes.CachedBucket{freshBucket}
for _, touchingBucket := range touchingBuckets {
if !freshBucket.Contains(touchingBucket) {
@@ -742,14 +753,11 @@ func (bc *bucketCache) unionBuckets(ctx context.Context, freshBucket *qbtypes.Ca
EndMs: freshBucket.EndMs,
Type: freshBucket.Type,
Value: value,
Stats: qbtypes.ExecStats{StepIntervals: freshBucket.Stats.StepIntervals},
Stats: freshBucket.Stats,
}
for _, mergedBucket := range bucketsToMerge {
unionBucket.StartMs = min(unionBucket.StartMs, mergedBucket.StartMs)
unionBucket.EndMs = max(unionBucket.EndMs, mergedBucket.EndMs)
unionBucket.Stats.RowsScanned += mergedBucket.Stats.RowsScanned
unionBucket.Stats.BytesScanned += mergedBucket.Stats.BytesScanned
unionBucket.Stats.DurationMS += mergedBucket.Stats.DurationMS
}
return unionBucket
}

View File

@@ -30,7 +30,7 @@ func BenchmarkBucketCache_GetMissRanges(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
}
// Create test queries with varying cache hit patterns
@@ -121,7 +121,7 @@ func BenchmarkBucketCache_Put(b *testing.B) {
for i := 0; i < b.N; i++ {
for j := 0; j < tc.numQueries; j++ {
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j])
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j], map[qbtypes.TimeRange]qbtypes.ExecStats{{From: queries[j].startMs, To: queries[j].endMs}: results[j].Stats})
}
}
})
@@ -259,7 +259,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
}
b.ResetTimer()
@@ -284,7 +284,7 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
case 2: // Partial read
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-%d", i%100),

View File

@@ -59,7 +59,7 @@ func TestBucketCacheStepAlignment(t *testing.T) {
}
// Put result in cache
bc.Put(ctx, orgID, query, step, result)
bc.Put(ctx, orgID, query, step, result, nil)
// Get cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
@@ -109,7 +109,7 @@ func TestBucketCacheNoStepInterval(t *testing.T) {
}
// Should cache the entire range when step is 0
bc.Put(ctx, orgID, query, step, result)
bc.Put(ctx, orgID, query, step, result, nil)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.NotNil(t, cached)

View File

@@ -156,7 +156,7 @@ func TestBucketCache_Put_And_Get(t *testing.T) {
}
// Store in cache
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Wait a bit for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -189,7 +189,7 @@ func TestBucketCache_PartialHit(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 3000, 1000),
}
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
// Wait for cache write
time.Sleep(10 * time.Millisecond)
@@ -225,7 +225,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
bc.Put(context.Background(), valuer.UUID{}, query1, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 2000, 100),
})
}, nil)
query2 := &mockQuery{
fingerprint: "test-query",
@@ -235,7 +235,7 @@ func TestBucketCache_MultipleBuckets(t *testing.T) {
bc.Put(context.Background(), valuer.UUID{}, query2, qbtypes.Step{Duration: 100 * time.Millisecond}, &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 4000, 100),
})
}, nil)
// Wait for cache writes
time.Sleep(10 * time.Millisecond)
@@ -280,7 +280,7 @@ func TestBucketCache_FluxInterval(t *testing.T) {
}
// This should not be cached due to flux interval
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Wait a bit
time.Sleep(10 * time.Millisecond)
@@ -358,7 +358,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
{Series: []*qbtypes.TimeSeries{series1}},
},
},
})
}, nil)
// Cache second part
query2 := &mockQuery{
@@ -374,7 +374,7 @@ func TestBucketCache_MergeTimeSeriesResults(t *testing.T) {
{Series: []*qbtypes.TimeSeries{series2}},
},
},
})
}, nil)
// Wait for cache writes
time.Sleep(10 * time.Millisecond)
@@ -441,7 +441,7 @@ func TestBucketCache_RawData(t *testing.T) {
Value: rawData,
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -481,7 +481,7 @@ func TestBucketCache_ScalarData(t *testing.T) {
Value: scalarData,
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -509,7 +509,7 @@ func TestBucketCache_EmptyFingerprint(t *testing.T) {
Value: createTestTimeSeries("A", 1000, 5000, 1000),
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Should still be able to retrieve
@@ -564,7 +564,7 @@ func TestBucketCache_ConcurrentAccess(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries(fmt.Sprintf("Q%d", id), query.startMs, query.endMs, 100),
}
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result)
bc.Put(context.Background(), valuer.UUID{}, query, qbtypes.Step{Duration: 100 * time.Microsecond}, result, nil)
done <- true
}(i)
}
@@ -624,7 +624,7 @@ func TestBucketCache_GetMissRanges_FluxInterval(t *testing.T) {
},
}
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, cachedResult, nil)
// Get miss ranges
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -686,7 +686,7 @@ func TestBucketCache_Put_FluxIntervalTrimming(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Retrieve cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -756,7 +756,7 @@ func TestBucketCache_Put_EntireRangeInFluxInterval(t *testing.T) {
}
// Put the result - should not cache anything
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Try to get cached data - should have no cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
@@ -862,7 +862,7 @@ func TestBucketCache_EmptyDataHandling(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, tt.result, nil)
// Wait a bit for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -927,7 +927,7 @@ func TestBucketCache_PartialValues(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: query.startMs, To: query.endMs}: result.Stats})
// Wait for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -997,7 +997,7 @@ func TestBucketCache_AllPartialValues(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
// Wait for cache to be written
time.Sleep(10 * time.Millisecond)
@@ -1058,7 +1058,7 @@ func TestBucketCache_FilteredCachedResults(t *testing.T) {
}
// Cache the wide range
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1)
bc.Put(ctx, orgID, query1, qbtypes.Step{Duration: 1000 * time.Millisecond}, result1, nil)
time.Sleep(10 * time.Millisecond)
// Now query for a smaller range (2000-3500ms)
@@ -1125,7 +1125,7 @@ func TestBucketCache_PromQLKeepsTheValueAtTheWindowEnd(t *testing.T) {
}},
}},
},
})
}, nil)
time.Sleep(10 * time.Millisecond)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
@@ -1291,7 +1291,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1345,7 +1345,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1397,7 +1397,7 @@ func TestBucketCache_PartialValueDetection(t *testing.T) {
}
// Put the result
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Get cached data
@@ -1454,7 +1454,7 @@ func TestBucketCache_NoCache(t *testing.T) {
}
// Put the result in cache
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result, nil)
time.Sleep(10 * time.Millisecond)
// Verify data is cached
@@ -1475,11 +1475,12 @@ func TestBucketCachePutSlicesAtCellBoundaries(t *testing.T) {
// a 1s step gives 60s cells, so 30s to 150s spans three of them
query := &mockQuery{fingerprint: "sliced-query", startMs: 30000, endMs: 150000}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 30000, 150000, 1000),
Stats: qbtypes.ExecStats{RowsScanned: 120, BytesScanned: 1200, DurationMS: 12},
})
}
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: 30000, To: 150000}: result.Stats})
type bucketShape struct {
cell, startMs, endMs, rowsScanned uint64
@@ -1539,7 +1540,7 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
name: "OverlappingWrites_BecomeOne",
firstWindow: qbtypes.TimeRange{From: 1000, To: 4000},
secondWindow: qbtypes.TimeRange{From: 3000, To: 6000},
expectedBuckets: []bucketShape{{1000, 6000, 6}},
expectedBuckets: []bucketShape{{1000, 6000, 5}},
expectedMissingRanges: []*qbtypes.TimeRange{},
expectedValueCount: 5,
},
@@ -1570,12 +1571,13 @@ func TestBucketCachePutAbsorbsWritesWithinACell(t *testing.T) {
for _, window := range []qbtypes.TimeRange{testCase.firstWindow, testCase.secondWindow} {
query := &mockQuery{fingerprint: "absorb-query", startMs: window.From, endMs: window.To}
bc.Put(ctx, orgID, query, step, &qbtypes.Result{
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", window.From, window.To, 1000),
// one row per second, so a bucket's stats read as the seconds it was scanned for
// one row per second, so a bucket's stats read as the seconds of data it holds
Stats: qbtypes.ExecStats{RowsScanned: (window.To - window.From) / 1000},
})
}
bc.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{window: result.Stats})
}
query := &mockQuery{
@@ -1609,7 +1611,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 1000, 10000, 1000),
Warnings: []string{"from the wide write"},
})
}, nil)
// a write already covered by the cell's bucket must not rewrite the entry,
// which shows as its warnings never arriving
@@ -1618,7 +1620,7 @@ func TestBucketCachePutLeavesAnUnchangedCellAlone(t *testing.T) {
Type: qbtypes.RequestTypeTimeSeries,
Value: createTestTimeSeries("A", 3000, 5000, 1000),
Warnings: []string{"from the inside write"},
})
}, nil)
var cellEntry qbtypes.CachedData
require.NoError(t, bc.cache.Get(ctx, orgID, bc.generateCacheKey(wide, 0), &cellEntry))

View File

@@ -823,7 +823,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
return nil, err
}
// Store in cache for future use
q.bucketCache.Put(ctx, orgID, query, step, result)
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
return result, nil
}
}
@@ -879,16 +879,19 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
if err != nil {
return nil, err
}
q.bucketCache.Put(ctx, orgID, query, step, result)
startMs, endMs := query.Window()
q.bucketCache.Put(ctx, orgID, query, step, result, map[qbtypes.TimeRange]qbtypes.ExecStats{{From: startMs, To: endMs}: result.Stats})
return result, nil
}
}
// Calculate total stats and filter out nil results
validResults := make([]*qbtypes.Result, 0, len(freshResults))
for _, result := range freshResults {
execStatsForQueriedTimeRanges := make(map[qbtypes.TimeRange]qbtypes.ExecStats, len(freshResults))
for i, result := range freshResults {
if result != nil {
validResults = append(validResults, result)
execStatsForQueriedTimeRanges[*missingRanges[i]] = result.Stats
totalStats.RowsScanned += result.Stats.RowsScanned
totalStats.BytesScanned += result.Stats.BytesScanned
totalStats.DurationMS += result.Stats.DurationMS
@@ -903,7 +906,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
mergedResult.Stats.DurationMS += totalStats.DurationMS
// Store merged result in cache
q.bucketCache.Put(ctx, orgID, query, step, mergedResult)
q.bucketCache.Put(ctx, orgID, query, step, mergedResult, execStatsForQueriedTimeRanges)
return mergedResult, nil
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/units"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -86,9 +85,6 @@ type BaseRule struct {
// newGroupEvalDelay is the grace period for new alert groups
newGroupEvalDelay valuer.TextDuration
useTemplates bool
notificationBody string
ruleStateHistoryModule rulestatehistory.Module
queryParser queryparser.QueryParser
@@ -177,11 +173,9 @@ func NewBaseRule(
evaluation: evaluation,
}
// Store newGroupEvalDelay, groupBy keys and template settings from NotificationSettings
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
if p.NotificationSettings != nil {
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
baseRule.useTemplates = p.NotificationSettings.UseTemplates
baseRule.notificationBody = p.NotificationSettings.NotificationBody
}
if baseRule.evalWindow.IsZero() {
@@ -278,47 +272,6 @@ func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Label
return annotations
}
// AlertAnnotations returns the expanded rule annotations plus the private ones
// the notifier reads into template fields. value and threshold are smpl.V and
// smpl.Target formatted for display.
func (r *BaseRule) AlertAnnotations(expand func(string) string, smpl ruletypes.Sample, value, threshold string) ruletypes.Labels {
annotations := r.ExpandAnnotations(expand)
if !smpl.IsMissing {
annotations = append(annotations,
ruletypes.Label{Name: ruletypes.AnnotationValue, Value: value},
ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold},
)
if op := smpl.CompareOperator.Literal(); op != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: op})
}
if matchType := smpl.MatchType.Literal(); matchType != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: matchType})
}
if smpl.RecoveryTarget != nil {
recovery := units.FormatterFromUnit(r.Unit()).Format(*smpl.RecoveryTarget, smpl.TargetUnit)
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRecoveryThreshold, Value: recovery})
}
}
if smpl.IsRecovering {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationIsRecovering, Value: "true"})
}
if signal := r.typ.Signal(); signal != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRuleType, Value: signal})
}
if r.useTemplates {
title := r.name
if smpl.IsMissing {
title = "[No data] " + title
}
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationTitleTemplate, Value: title})
if r.notificationBody != "" {
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationBodyTemplate, Value: r.notificationBody})
}
}
return annotations
}
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
u := r.externalURL.JoinPath(path)
if len(params) > 0 {

View File

@@ -885,77 +885,3 @@ func TestExpandAnnotations(t *testing.T) {
})
}
}
func TestAlertAnnotations(t *testing.T) {
recoveryTarget := 400.0
testCases := []struct {
name string
alertType ruletypes.AlertType
useTemplates bool
notificationBody string
smpl ruletypes.Sample
want map[string]string
}{
{
name: "Firing_AddsValueAndThreshold",
smpl: ruletypes.Sample{},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms"},
},
{
name: "Recovering_AddsValueThresholdAndRecoveringFlag",
smpl: ruletypes.Sample{IsRecovering: true},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationIsRecovering: "true"},
},
{
name: "OperatorAndRecoveryTarget_AddsLiteralOpAndRecoveryThreshold",
smpl: ruletypes.Sample{CompareOperator: ruletypes.ValueIsAboveSymbol, RecoveryTarget: &recoveryTarget, TargetUnit: "ms"},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationCompareOp: "above", ruletypes.AnnotationRecoveryThreshold: "400 ms"},
},
{
name: "MatchTypeAndAlertType_AddsLiteralMatchTypeAndSignal",
alertType: ruletypes.AlertTypeLogs,
smpl: ruletypes.Sample{MatchType: ruletypes.OnAverageShort},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationMatchType: "on_average", ruletypes.AnnotationRuleType: "logs"},
},
{
name: "MissingData_OmitsValueAndThreshold_KeepsRuleType",
alertType: ruletypes.AlertTypeMetric,
smpl: ruletypes.Sample{IsMissing: true, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce, RecoveryTarget: &recoveryTarget},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationRuleType: "metrics"},
},
{
name: "TemplatesOff_OmitsTitleAndBody",
notificationBody: "$alert.value errors",
smpl: ruletypes.Sample{},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms"},
},
{
name: "TemplatesOn_AddsTitleAndBody",
useTemplates: true,
notificationBody: "$alert.value errors",
smpl: ruletypes.Sample{},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationTitleTemplate: "High errors on $labels.service.name", ruletypes.AnnotationBodyTemplate: "$alert.value errors"},
},
{
name: "TemplatesOnWithoutBody_AddsTitleOnly",
useTemplates: true,
smpl: ruletypes.Sample{},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationValue: "523 ms", ruletypes.AnnotationThresholdValue: "500 ms", ruletypes.AnnotationTitleTemplate: "High errors on $labels.service.name"},
},
{
name: "TemplatesOnMissingData_PrefixesTitle",
useTemplates: true,
smpl: ruletypes.Sample{IsMissing: true},
want: map[string]string{"summary": "high errors", ruletypes.AnnotationTitleTemplate: "[No data] High errors on $labels.service.name"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
rule := &BaseRule{name: "High errors on $labels.service.name", typ: testCase.alertType, useTemplates: testCase.useTemplates, notificationBody: testCase.notificationBody, annotations: ruletypes.FromMap(map[string]string{"summary": "high errors"}), ruleCondition: &ruletypes.RuleCondition{CompositeQuery: &ruletypes.AlertCompositeQuery{Unit: "ms"}}}
expand := func(text string) string { return text }
assert.Equal(t, testCase.want, rule.AlertAnnotations(expand, testCase.smpl, "523 ms", "500 ms").Map())
})
}
}

View File

@@ -177,10 +177,9 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
}
r.logger.DebugContext(ctx, "alerting for series", slog.Any("series", result))
value := valueFormatter.Format(result.V, r.Unit())
threshold := valueFormatter.Format(result.Target, result.TargetUnit)
tmplData := ruletypes.AlertTemplateData(l, value, threshold)
tmplData := ruletypes.AlertTemplateData(l, valueFormatter.Format(result.V, r.Unit()), threshold)
// Inject some convenience variables that are easier to remember for users
// who are not used to Go's templating system.
defs := "{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"
@@ -212,7 +211,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.AlertAnnotations(expand, result, value, threshold)
annotations := r.ExpandAnnotations(expand)
if result.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -944,12 +944,6 @@ func TestPromRuleUnitCombinations(t *testing.T) {
if c.expectAlerts != 0 {
foundCount := 0
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
for _, summary := range c.summaryAny {
if strings.Contains(item.Annotations.Get("summary"), summary) {
foundCount++
@@ -1054,8 +1048,6 @@ func TestPromRuleNoData(t *testing.T) {
for _, item := range rule.Active {
if c.expectNoData {
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
} else {
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
}

View File

@@ -125,17 +125,6 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
}
func (r *ThresholdRule) prepareParamsForExceptions(ctx context.Context, ts time.Time, resultLabels ruletypes.Labels) url.Values {
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
}
start := time.UnixMilli(int64(qr.Start))
end := time.UnixMilli(int64(qr.End))
return contextlinks.PrepareParamsForExceptions(start, end, resultLabels.Map())
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ctx, ts)
if err != nil {
@@ -296,7 +285,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
annotations := r.AlertAnnotations(expand, smpl, value, threshold)
annotations := r.ExpandAnnotations(expand)
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
@@ -324,13 +313,6 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
}
case ruletypes.AlertTypeExceptions:
params := r.prepareParamsForExceptions(ctx, ts, smpl.Metric)
if len(params) > 0 {
link := r.ExternalURL("exceptions", params)
r.logger.InfoContext(ctx, "adding exceptions link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedExceptions, Value: link})
}
}
lbs := lb.Labels()

View File

@@ -208,60 +208,6 @@ func TestPrepareParamsForLogs(t *testing.T) {
assert.JSONEq(t, `{"queryType":"builder","builder":{"queryData":[{"dataSource":"logs","filter":{}}],"queryFormulas":[]}}`, compositeQuery)
}
func TestPrepareParamsForExceptions(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Exceptions In Checkout"),
AlertType: ruletypes.AlertTypeExceptions,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeClickHouseSQL,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeClickHouseSQL,
Spec: qbtypes.ClickHouseQuery{
Name: "A",
Query: "SELECT count() AS value, toStartOfInterval(timestamp, toIntervalMinute(1)) AS interval, serviceName FROM signoz_traces.distributed_signoz_error_index_v2 WHERE timestamp BETWEEN {{.start_datetime}} AND {{.end_datetime}} GROUP BY serviceName, interval",
},
},
},
},
CompareOperator: ruletypes.ValueIsAbove,
MatchType: ruletypes.AtleastOnce,
Target: &[]float64{10.0}[0],
SelectedQuery: "A",
},
}
logger := instrumentationtest.New().Logger()
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
TargetValue: postableRule.RuleCondition.Target,
MatchType: postableRule.RuleCondition.MatchType,
CompareOperator: postableRule.RuleCondition.CompareOperator,
},
},
}
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("70", valuer.GenerateUUID(), &postableRule, nil, logger, externalURL, WithEvalDelay(valuer.MustParseTextDuration("2m")))
require.NoError(t, err)
ts := time.UnixMilli(1705469040000)
params := rule.prepareParamsForExceptions(context.Background(), ts, ruletypes.FromMap(map[string]string{"serviceName": "checkout"}))
assert.Equal(t, "1705468620000", params.Get("startTime"))
assert.Equal(t, "1705468920000", params.Get("endTime"))
assert.Equal(t, "checkout", params.Get("serviceName"))
assert.False(t, params.Has("exceptionType"))
}
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
@@ -744,14 +690,6 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
if c.expectAlerts != 0 {
foundCount := 0
for _, item := range rule.Active {
value := item.Annotations.Get(ruletypes.AnnotationValue)
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), value, "case %d", idx)
threshold := item.Annotations.Get(ruletypes.AnnotationThresholdValue)
assert.NotEmpty(t, threshold, "case %d", idx)
assert.Contains(t, item.Annotations.Get("summary"), threshold, "case %d", idx)
assert.Equal(t, c.compareOperator.Literal(), item.Annotations.Get(ruletypes.AnnotationCompareOp), "case %d", idx)
assert.Equal(t, c.matchType.Literal(), item.Annotations.Get(ruletypes.AnnotationMatchType), "case %d", idx)
for _, summary := range c.summaryAny {
if strings.Contains(item.Annotations.Get("summary"), summary) {
foundCount++
@@ -861,8 +799,6 @@ func TestThresholdRuleNoData(t *testing.T) {
for _, item := range rule.Active {
if c.expectNoData {
assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationValue), "case %d", idx)
assert.Empty(t, item.Annotations.Get(ruletypes.AnnotationThresholdValue), "case %d", idx)
} else {
assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
}

View File

@@ -3,7 +3,6 @@ package alertmanagertypes
import (
"bytes"
"net/url"
"time"
tmplhtml "html/template"
tmpltext "text/template"
@@ -20,7 +19,6 @@ func AdditionalFuncMap() tmpltext.FuncMap {
"urlescape": func(value string) tmplhtml.HTML {
return tmplhtml.HTML(url.QueryEscape(value))
},
"now": time.Now,
}
}

View File

@@ -76,14 +76,8 @@ type AlertData struct {
Rule RuleInfo `json:"rule" mapstructure:"rule"`
Log LinkInfo `json:"log" mapstructure:"log"`
Trace LinkInfo `json:"trace" mapstructure:"trace"`
Exception LinkInfo `json:"exception" mapstructure:"exception"`
Labels template.KV `json:"labels" mapstructure:"labels"`
Annotations template.KV `json:"annotations" mapstructure:"annotations"`
// Value and Threshold back the $value and $threshold shortcuts for
// $alert.value and $rule.threshold.value.
Value string `json:"value" mapstructure:"value"`
Threshold string `json:"threshold" mapstructure:"threshold"`
}
// AlertInfo holds the per-alert state and timing data.
@@ -99,7 +93,6 @@ type AlertInfo struct {
IsResolved bool `json:"isResolved" mapstructure:"is_resolved"`
IsMissingData bool `json:"isMissingData" mapstructure:"is_missing_data"`
IsRecovering bool `json:"isRecovering" mapstructure:"is_recovering"`
IsTest bool `json:"isTest" mapstructure:"is_test"`
}
// RuleInfo holds the rule metadata extracted from well-known labels and
@@ -109,20 +102,18 @@ type RuleInfo struct {
ID string `json:"id" mapstructure:"id"`
URL string `json:"url" mapstructure:"url"`
Severity string `json:"severity" mapstructure:"severity"`
Type string `json:"type" mapstructure:"type"`
MatchType string `json:"matchType" mapstructure:"match_type"`
Threshold Threshold `json:"threshold" mapstructure:"threshold"`
}
// Threshold describes the breach condition.
type Threshold struct {
Value string `json:"value" mapstructure:"value"`
Op string `json:"op" mapstructure:"op"`
RecoveryValue string `json:"recoveryValue" mapstructure:"recovery_value"`
Value string `json:"value" mapstructure:"value"`
Op string `json:"op" mapstructure:"op"`
}
// LinkInfo groups a single URL so templates can reference $log.url,
// $trace.url and $exception.url uniformly.
// LinkInfo groups a single URL so templates can reference $log.url and
// $trace.url uniformly.
type LinkInfo struct {
URL string `json:"url" mapstructure:"url"`
}
@@ -146,10 +137,8 @@ type NotificationTemplateData struct {
// NotificationAlert holds the aggregate alert fields available to title
// templates (counts, overall status, receiver).
type NotificationAlert struct {
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
TotalRecovering int `json:"totalRecovering" mapstructure:"total_recovering"`
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
IsTest bool `json:"isTest" mapstructure:"is_test"`
Receiver string `json:"receiver" mapstructure:"receiver"`
Status string `json:"status" mapstructure:"status"`
TotalFiring int `json:"totalFiring" mapstructure:"total_firing"`
TotalResolved int `json:"totalResolved" mapstructure:"total_resolved"`
}

View File

@@ -43,6 +43,23 @@ func (c *CachedBucket) Touches(other *CachedBucket) bool {
return c.StartMs <= other.EndMs && other.StartMs <= c.EndMs
}
// ProrateStatsToWindow returns the share of the stats for the part of the
// bucket inside [startMs, endMs).
func (c *CachedBucket) ProrateStatsToWindow(startMs, endMs uint64) ExecStats {
return c.Stats.prorate(overlapMs(c.StartMs, c.EndMs, startMs, endMs), c.EndMs-c.StartMs)
}
// ProrateStatsToUnqueried returns the share of the stats for the part of the
// bucket no queried range covers.
func (c *CachedBucket) ProrateStatsToUnqueried(execStatsForQueriedTimeRanges map[TimeRange]ExecStats) ExecStats {
widthMs := c.EndMs - c.StartMs
queriedMs := uint64(0)
for queried := range execStatsForQueriedTimeRanges {
queriedMs += overlapMs(c.StartMs, c.EndMs, queried.From, queried.To)
}
return c.Stats.prorate(widthMs-min(queriedMs, widthMs), widthMs)
}
// CachedData represents the full cached data for a query.
type CachedData struct {
Buckets []*CachedBucket `json:"buckets"`
@@ -88,7 +105,7 @@ func (c *CachedData) Cost() int64 {
return size
}
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) ([]*CachedBucket, error) {
func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64, execStatsForQueriedTimeRanges map[TimeRange]ExecStats) ([]*CachedBucket, error) {
tsData, ok := r.Value.(*TimeSeriesData)
if !ok || tsData == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "result of type %s cannot be bucketed", r.Type.StringValue())
@@ -111,18 +128,39 @@ func (r *Result) SplitIntoCacheCellBuckets(startMs, endMs, cellWidthMs uint64) (
if err != nil {
return nil, err
}
// A slice costs what was queried inside it; the rest of the result came
// out of the cache and is costed there already
sliceStats := ExecStats{StepIntervals: maps.Clone(r.Stats.StepIntervals)}
for queried, stats := range execStatsForQueriedTimeRanges {
queriedMs := overlapMs(from, to, queried.From, queried.To)
if queriedMs == 0 {
continue
}
share := stats.prorate(queriedMs, queried.To-queried.From)
sliceStats.RowsScanned += share.RowsScanned
sliceStats.BytesScanned += share.BytesScanned
sliceStats.DurationMS += share.DurationMS
}
cellBuckets = append(cellBuckets, &CachedBucket{
StartMs: from,
EndMs: to,
Type: r.Type,
Value: value,
Stats: r.Stats.prorate(to-from, endMs-startMs),
Stats: sliceStats,
})
from = to
}
return cellBuckets, nil
}
// overlapMs is the length of [aFromMs, aToMs) ∩ [bFromMs, bToMs).
func overlapMs(aFromMs, aToMs, bFromMs, bToMs uint64) uint64 {
if fromMs, toMs := max(aFromMs, bFromMs), min(aToMs, bToMs); fromMs < toMs {
return toMs - fromMs
}
return 0
}
// prorate returns the share of the stats that partMs out of wholeMs of the
// scanned window accounts for.
func (e ExecStats) prorate(partMs, wholeMs uint64) ExecStats {

View File

@@ -39,25 +39,6 @@ func (AlertType) Enum() []any {
}
}
// Signal returns the signal the alert type evaluates (metrics, logs, traces,
// exceptions or ai_traces), or an empty string for an unknown type.
func (t AlertType) Signal() string {
switch t {
case AlertTypeMetric:
return "metrics"
case AlertTypeLogs:
return "logs"
case AlertTypeTraces:
return "traces"
case AlertTypeExceptions:
return "exceptions"
case AlertTypeAITraces:
return "ai_traces"
default:
return ""
}
}
// BuilderQueryType returns the query type the alert type's builder queries
// carry; only AI trace alerts use builder_ai_query.
func (t AlertType) BuilderQueryType() qbtypes.QueryType {
@@ -110,9 +91,6 @@ type NotificationSettings struct {
Renotify *Renotify `json:"renotify,omitempty" description:"Whether and how often to re-send a notification for an alert that stays active."`
UsePolicy bool `json:"usePolicy" description:"When true, alerts are routed by the organisation level notification policies instead of the channels named on the thresholds. Defaults to false."`
NewGroupEvalDelay valuer.TextDuration `json:"newGroupEvalDelay,omitzero" description:"A grace period, as a Go duration such as 2m, during which a newly seen label group is excluded from evaluation. Applies to metric rules only; log and trace rules are never delayed. A group whose values are not yet known is evaluated immediately."`
UseTemplates bool `json:"useTemplates,omitempty" description:"When true, the alert name is the notification title and notificationBody the body, both rendered as templates with variables such as $labels.service.name and $alert.value. When false, channels use their own title and body. Defaults to false."`
NotificationBody string `json:"notificationBody,omitempty" description:"The notification body template, read when useTemplates is true. Channels fall back to their own body when it is empty."`
IncludeSamples bool `json:"includeSamples,omitempty" description:"Whether notifications include sample data from the alert window. Not read yet. Defaults to false."`
}
type Renotify struct {

View File

@@ -1352,24 +1352,3 @@ func TestVersionDefaultsToV5(t *testing.T) {
assert.Equal(t, "v5", rule.Version)
assert.NoError(t, rule.Validate())
}
func TestAlertTypeSignal(t *testing.T) {
testCases := []struct {
name string
alertType AlertType
want string
}{
{name: "Metric", alertType: AlertTypeMetric, want: "metrics"},
{name: "Logs", alertType: AlertTypeLogs, want: "logs"},
{name: "Traces", alertType: AlertTypeTraces, want: "traces"},
{name: "Exceptions", alertType: AlertTypeExceptions, want: "exceptions"},
{name: "AITraces", alertType: AlertTypeAITraces, want: "ai_traces"},
{name: "Unknown_Empty", alertType: "", want: ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.alertType.Signal())
})
}
}

View File

@@ -13,34 +13,29 @@ const (
LabelNoData = "nodata"
LabelTestAlert = "testalert"
LabelAlertName = "alertname"
LabelIsRecovering = "is_recovering"
)
// Annotations set by the rule manager and consumed by the alertmanager
// templating layer.
//
// Those prefixed with "_" are private: the alertmanager reads them into
// structured template fields, then strips them from notification-visible
// surfaces by alertmanagertypes.FilterPublicAnnotations before rendering.
// The raw template strings, the breached value ($alert.value), the configured
// threshold ($rule.threshold.value), its comparator ($rule.threshold.op), the
// recovery threshold ($rule.threshold.recovery_value), the match type
// ($rule.match_type), the rule's signal ($rule.type), the exceptions page link
// ($exception.url) and the recovering flag ($alert.is_recovering) are private.
// Those prefixed with "_" are private: they're stripped from
// notification-visible surfaces by alertmanagertypes.FilterPublicAnnotations
// before rendering. Only the raw template strings are private — echoing
// them into a notification is circular and never useful.
//
// The rest are public: they are deep links to relevant logs/traces and users
// may reference them directly as {{ .Annotations.related_logs }}, etc. in
// The rest are public: they describe the firing alert (the breached value,
// the configured threshold, the comparator, the match type, and deep links
// to relevant logs/traces) and users may reference them directly as
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
// their channel templates.
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationRelatedExceptions = "_related_exceptions"
AnnotationValue = "_value"
AnnotationIsRecovering = "_is_recovering"
AnnotationThresholdValue = "_threshold"
AnnotationRecoveryThreshold = "_recovery_threshold"
AnnotationCompareOp = "_compare_op"
AnnotationMatchType = "_match_type"
AnnotationRuleType = "_rule_type"
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
)

View File

@@ -62,7 +62,6 @@
<p style="margin:0;font-size:14px;color:#666">
Status: <strong>{{.Alert.Status}}</strong>
{{if .Alert.TotalFiring}} | Firing: <strong style="color:#e53e3e">{{.Alert.TotalFiring}}</strong>{{end}}
{{if .Alert.TotalRecovering}} | Recovering: <strong style="color:#dd6b20">{{.Alert.TotalRecovering}}</strong>{{end}}
{{if .Alert.TotalResolved}} | Resolved: <strong style="color:#38a169">{{.Alert.TotalResolved}}</strong>{{end}}
</p>
</td>

View File

@@ -442,3 +442,55 @@ def test_builder_refreshing_a_sliding_time_range(
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")
def test_builder_refreshing_does_not_inflate_rows_scanned(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_rows_scanned_{uuid4().hex[:8]}"
# 90 minutes back so even the last refresh closes clear of the flux
# interval, which holds recent data out of the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max")]
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=minute,
type_="Gauge",
is_monotonic=False,
)
for minute in range(70)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# a dashboard left open on a thirty minute range, re-running a minute later
# each time. rowsScanned is a cost indicator, so a refresh served mostly out
# of the cache cannot claim more than every read made so far: the first
# full read and then one new minute per refresh, measured with the cache off
uncached_rows_scanned_so_far = 0
for refresh in range(10):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
refresh_end_ms = refresh_start_ms + 30 * MINUTE_MS
uncached_start_ms = refresh_start_ms if refresh == 0 else refresh_end_ms - MINUTE_MS
uncached_response = make_query_request(signoz, token, uncached_start_ms, refresh_end_ms, query, no_cache=True)
assert uncached_response.status_code == HTTPStatus.OK, f"refresh {refresh}: uncached read failed: {uncached_response.text}"
uncached_rows_scanned_so_far += uncached_response.json()["data"]["meta"]["rowsScanned"]
assert uncached_rows_scanned_so_far > 0, f"refresh {refresh}: uncached read reported no rows scanned"
cached_response = make_query_request(signoz, token, refresh_start_ms, refresh_end_ms, query, no_cache=False)
assert cached_response.status_code == HTTPStatus.OK, f"refresh {refresh}: cached read failed: {cached_response.text}"
cached_rows_scanned = cached_response.json()["data"]["meta"]["rowsScanned"]
assert cached_rows_scanned <= uncached_rows_scanned_so_far, f"refresh {refresh}: cached read reported {cached_rows_scanned} rows scanned, more than the {uncached_rows_scanned_so_far} rows all uncached reads scanned so far"