Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
ce4c3c2124 feat(global-time-store): add support to context, url persistence, store persistence, drift handle 2026-04-23 16:23:16 -03:00
92 changed files with 3888 additions and 4116 deletions

View File

@@ -39,7 +39,6 @@ jobs:
matrix:
suite:
- alerts
- alertmanager
- callbackauthn
- cloudintegrations
- dashboard

View File

@@ -668,8 +668,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
required:
- aws
type: object
CloudintegrationtypesAgentReport:
nullable: true
@@ -693,90 +693,6 @@ components:
nullable: true
type: array
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
deploymentRegion:
type: string
resourceGroups:
items:
type: string
type: array
required:
- deploymentRegion
- resourceGroups
type: object
CloudintegrationtypesAzureConnectionArtifact:
properties:
cliCommand:
type: string
cloudPowerShellCommand:
type: string
required:
- cliCommand
- cloudPowerShellCommand
type: object
CloudintegrationtypesAzureIntegrationConfig:
properties:
deploymentRegion:
type: string
resourceGroups:
items:
type: string
type: array
telemetryCollectionStrategy:
items:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
type: array
required:
- deploymentRegion
- resourceGroups
- telemetryCollectionStrategy
type: object
CloudintegrationtypesAzureLogsCollectionStrategy:
properties:
categoryGroups:
items:
type: string
type: array
required:
- categoryGroups
type: object
CloudintegrationtypesAzureMetricsCollectionStrategy:
type: object
CloudintegrationtypesAzureServiceConfig:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceLogsConfig'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceMetricsConfig'
required:
- logs
- metrics
type: object
CloudintegrationtypesAzureServiceLogsConfig:
properties:
enabled:
type: boolean
type: object
CloudintegrationtypesAzureServiceMetricsConfig:
properties:
enabled:
type: boolean
type: object
CloudintegrationtypesAzureTelemetryCollectionStrategy:
properties:
logs:
$ref: '#/components/schemas/CloudintegrationtypesAzureLogsCollectionStrategy'
metrics:
$ref: '#/components/schemas/CloudintegrationtypesAzureMetricsCollectionStrategy'
resourceProvider:
type: string
resourceType:
type: string
required:
- resourceProvider
- resourceType
type: object
CloudintegrationtypesCloudIntegrationService:
nullable: true
properties:
@@ -821,8 +737,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSConnectionArtifact'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureConnectionArtifact'
required:
- aws
type: object
CloudintegrationtypesCredentials:
properties:
@@ -994,8 +910,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSPostableAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureAccountConfig'
required:
- aws
type: object
CloudintegrationtypesPostableAgentCheckIn:
properties:
@@ -1018,8 +934,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSIntegrationConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureIntegrationConfig'
required:
- aws
type: object
CloudintegrationtypesService:
properties:
@@ -1056,8 +972,8 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSServiceConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureServiceConfig'
required:
- aws
type: object
CloudintegrationtypesServiceID:
enum:
@@ -1074,8 +990,6 @@ components:
- s3sync
- sns
- sqs
- storageaccountsblob
- cdnprofile
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -1104,32 +1018,16 @@ components:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSTelemetryCollectionStrategy'
azure:
$ref: '#/components/schemas/CloudintegrationtypesAzureTelemetryCollectionStrategy'
required:
- aws
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAccountConfig'
$ref: '#/components/schemas/CloudintegrationtypesAccountConfig'
required:
- config
type: object
CloudintegrationtypesUpdatableAccountConfig:
properties:
aws:
$ref: '#/components/schemas/CloudintegrationtypesAWSAccountConfig'
azure:
$ref: '#/components/schemas/CloudintegrationtypesUpdatableAzureAccountConfig'
type: object
CloudintegrationtypesUpdatableAzureAccountConfig:
properties:
resourceGroups:
items:
type: string
type: array
required:
- resourceGroups
type: object
CloudintegrationtypesUpdatableService:
properties:
config:

View File

@@ -277,23 +277,8 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
for name, value := range r.Annotations().Map() {
// no need to expand custom templating annotations — they get expanded in the notifier layer
if ruletypes.IsCustomTemplatingAnnotation(name) {
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
continue
}
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
// Add values to be used in notifier layer for notification templates
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationValue, Value: value})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: smpl.CompareOperator.Literal()})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: smpl.MatchType.Literal()})
if smpl.IsRecovering {
lb.Set(ruletypes.LabelIsRecovering, "true")
}
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -795,8 +795,7 @@ export interface CloudintegrationtypesAccountDTO {
}
export interface CloudintegrationtypesAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
aws: CloudintegrationtypesAWSAccountConfigDTO;
}
/**
@@ -830,86 +829,6 @@ export interface CloudintegrationtypesAssetsDTO {
dashboards?: CloudintegrationtypesDashboardDTO[] | null;
}
export interface CloudintegrationtypesAzureAccountConfigDTO {
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
resourceGroups: string[];
}
export interface CloudintegrationtypesAzureConnectionArtifactDTO {
/**
* @type string
*/
cliCommand: string;
/**
* @type string
*/
cloudPowerShellCommand: string;
}
export interface CloudintegrationtypesAzureIntegrationConfigDTO {
/**
* @type string
*/
deploymentRegion: string;
/**
* @type array
*/
resourceGroups: string[];
/**
* @type array
*/
telemetryCollectionStrategy: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO[];
}
export interface CloudintegrationtypesAzureLogsCollectionStrategyDTO {
/**
* @type array
*/
categoryGroups: string[];
}
export interface CloudintegrationtypesAzureMetricsCollectionStrategyDTO {
[key: string]: unknown;
}
export interface CloudintegrationtypesAzureServiceConfigDTO {
logs: CloudintegrationtypesAzureServiceLogsConfigDTO;
metrics: CloudintegrationtypesAzureServiceMetricsConfigDTO;
}
export interface CloudintegrationtypesAzureServiceLogsConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface CloudintegrationtypesAzureServiceMetricsConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface CloudintegrationtypesAzureTelemetryCollectionStrategyDTO {
logs?: CloudintegrationtypesAzureLogsCollectionStrategyDTO;
metrics?: CloudintegrationtypesAzureMetricsCollectionStrategyDTO;
/**
* @type string
*/
resourceProvider: string;
/**
* @type string
*/
resourceType: string;
}
/**
* @nullable
*/
@@ -971,8 +890,7 @@ export interface CloudintegrationtypesCollectedMetricDTO {
}
export interface CloudintegrationtypesConnectionArtifactDTO {
aws?: CloudintegrationtypesAWSConnectionArtifactDTO;
azure?: CloudintegrationtypesAzureConnectionArtifactDTO;
aws: CloudintegrationtypesAWSConnectionArtifactDTO;
}
export interface CloudintegrationtypesCredentialsDTO {
@@ -1156,8 +1074,7 @@ export interface CloudintegrationtypesPostableAccountDTO {
}
export interface CloudintegrationtypesPostableAccountConfigDTO {
aws?: CloudintegrationtypesAWSPostableAccountConfigDTO;
azure?: CloudintegrationtypesAzureAccountConfigDTO;
aws: CloudintegrationtypesAWSPostableAccountConfigDTO;
}
/**
@@ -1192,8 +1109,7 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
}
export interface CloudintegrationtypesProviderIntegrationConfigDTO {
aws?: CloudintegrationtypesAWSIntegrationConfigDTO;
azure?: CloudintegrationtypesAzureIntegrationConfigDTO;
aws: CloudintegrationtypesAWSIntegrationConfigDTO;
}
export interface CloudintegrationtypesServiceDTO {
@@ -1221,8 +1137,7 @@ export interface CloudintegrationtypesServiceDTO {
}
export interface CloudintegrationtypesServiceConfigDTO {
aws?: CloudintegrationtypesAWSServiceConfigDTO;
azure?: CloudintegrationtypesAzureServiceConfigDTO;
aws: CloudintegrationtypesAWSServiceConfigDTO;
}
export enum CloudintegrationtypesServiceIDDTO {
@@ -1239,8 +1154,6 @@ export enum CloudintegrationtypesServiceIDDTO {
s3sync = 's3sync',
sns = 'sns',
sqs = 'sqs',
storageaccountsblob = 'storageaccountsblob',
cdnprofile = 'cdnprofile',
}
export interface CloudintegrationtypesServiceMetadataDTO {
/**
@@ -1273,24 +1186,11 @@ export interface CloudintegrationtypesSupportedSignalsDTO {
}
export interface CloudintegrationtypesTelemetryCollectionStrategyDTO {
aws?: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
azure?: CloudintegrationtypesAzureTelemetryCollectionStrategyDTO;
aws: CloudintegrationtypesAWSTelemetryCollectionStrategyDTO;
}
export interface CloudintegrationtypesUpdatableAccountDTO {
config: CloudintegrationtypesUpdatableAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAccountConfigDTO {
aws?: CloudintegrationtypesAWSAccountConfigDTO;
azure?: CloudintegrationtypesUpdatableAzureAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableAzureAccountConfigDTO {
/**
* @type array
*/
resourceGroups: string[];
config: CloudintegrationtypesAccountConfigDTO;
}
export interface CloudintegrationtypesUpdatableServiceDTO {

View File

@@ -138,8 +138,8 @@ function ChartPreview({
if (startTime && endTime && startTime !== endTime) {
dispatch(
UpdateTimeInterval('custom', [
Number.parseInt(getTimeString(startTime), 10),
Number.parseInt(getTimeString(endTime), 10),
parseInt(getTimeString(startTime), 10),
parseInt(getTimeString(endTime), 10),
]),
);
}

View File

@@ -369,7 +369,7 @@ function FormAlertRules({
// onQueryCategoryChange handles changes to query category
// in state as well as sets additional defaults
const onQueryCategoryChange = (val: EQueryType): void => {
const element = document.querySelector('#top');
const element = document.getElementById('top');
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}

View File

@@ -280,7 +280,7 @@ function Explorer(): JSX.Element {
[],
);
const [warning, setWarning] = useState<Warning | undefined>();
const [warning, setWarning] = useState<Warning | undefined>(undefined);
const oneChartPerQueryDisabledTooltip = useMemo(() => {
if (splitedQueries.length <= 1) {
@@ -292,7 +292,7 @@ function Explorer(): JSX.Element {
if (disableOneChartPerQuery) {
return 'One chart per query cannot be disabled for multiple queries with different units.';
}
return;
return undefined;
}, [disableOneChartPerQuery, splitedQueries.length, units.length]);
// Show the y axis unit selector if -

View File

@@ -221,7 +221,7 @@ function Inspect({
);
}
if (inspectMetricsTimeSeries.length === 0) {
if (!inspectMetricsTimeSeries.length) {
return renderFallback(
'inspect-metrics-empty',
<Empty description="No time series found for this metric to inspect." />,

View File

@@ -256,10 +256,10 @@ export function useInspectMetrics(
const valuesMap = new Map<number, number>();
series.values.forEach(({ timestamp, value }) => {
valuesMap.set(timestamp, Number.parseFloat(value));
valuesMap.set(timestamp, parseFloat(value));
});
return timestamps.map((timestamp) => valuesMap.get(timestamp) ?? Number.NaN);
return timestamps.map((timestamp) => valuesMap.get(timestamp) ?? NaN);
});
const rawData = [timestamps, ...timeseriesArray];
@@ -273,7 +273,7 @@ export function useInspectMetrics(
labels.add(label);
});
});
return [...labels];
return Array.from(labels);
}, [inspectMetricsData]);
const reset = useCallback(() => {

View File

@@ -18,7 +18,7 @@ describe('RunQueryBtn', () => {
);
});
it('renders run state and triggers on click', async () => {
test('renders run state and triggers on click', async () => {
const user = userEvent.setup();
const onRun = jest.fn();
const onCancel = jest.fn();
@@ -35,7 +35,7 @@ describe('RunQueryBtn', () => {
expect(onRun).toHaveBeenCalledTimes(1);
});
it('shows cancel state and calls handleCancelQuery', async () => {
test('shows cancel state and calls handleCancelQuery', async () => {
const user = userEvent.setup();
const onRun = jest.fn();
const onCancel = jest.fn();
@@ -51,19 +51,19 @@ describe('RunQueryBtn', () => {
expect(onCancel).toHaveBeenCalledTimes(1);
});
it('disabled when disabled prop is true', () => {
test('disabled when disabled prop is true', () => {
render(<RunQueryBtn disabled />);
expect(screen.getByRole('button', { name: /run query/i })).toBeDisabled();
});
it('disabled when no props provided', () => {
test('disabled when no props provided', () => {
render(<RunQueryBtn />);
expect(
screen.getByRole('button', { name: /run query/i }),
).toBeInTheDocument();
});
it('shows Command + CornerDownLeft on mac', () => {
test('shows Command + CornerDownLeft on mac', () => {
const { container } = render(
<RunQueryBtn
onStageRunQuery={jest.fn()}
@@ -77,7 +77,7 @@ describe('RunQueryBtn', () => {
).toBeInTheDocument();
});
it('shows ChevronUp + CornerDownLeft on non-mac', () => {
test('shows ChevronUp + CornerDownLeft on non-mac', () => {
(getUserOperatingSystem as jest.Mock).mockReturnValue(
UserOperatingSystem.WINDOWS,
);
@@ -95,7 +95,7 @@ describe('RunQueryBtn', () => {
).toBeInTheDocument();
});
it('renders custom label when provided', () => {
test('renders custom label when provided', () => {
render(
<RunQueryBtn
onStageRunQuery={jest.fn()}

View File

@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -128,35 +128,33 @@ function DateTimeSelection({
}
}, [modalInitialStartTime, modalInitialEndTime]);
const {
localstorageStartTime,
localstorageEndTime,
} = ((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
const { localstorageStartTime, localstorageEndTime } =
((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
}
return { localstorageStartTime: null, localstorageEndTime: null };
})();
return { localstorageStartTime: null, localstorageEndTime: null };
})();
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
@@ -183,9 +181,8 @@ function DateTimeSelection({
const [options, setOptions] = useState(getOptions(location.pathname));
const [refreshButtonHidden, setRefreshButtonHidden] = useState<boolean>(false);
const [customDateTimeVisible, setCustomDTPickerVisible] = useState<boolean>(
false,
);
const [customDateTimeVisible, setCustomDTPickerVisible] =
useState<boolean>(false);
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();

View File

@@ -1,2 +0,0 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -164,8 +164,7 @@ export function useIntegrationModal({
{
onSuccess: (response: CreateAccountMutationResult) => {
const accountId = response.data.id;
const connectionUrl =
response.data.connectionArtifact.aws?.connectionUrl ?? '';
const connectionUrl = response.data.connectionArtifact.aws.connectionUrl;
logEvent(
'AWS Integration: Account connection attempt redirected to AWS',

View File

@@ -115,8 +115,8 @@ export const useGetQueryRange: UseGetQueryRange = (
const updatedQuery = updateBarStepInterval(
requestData.query,
requestData.start ? requestData.start * 1e3 : Number.parseInt(start, 10) * 1e3,
requestData.end ? requestData.end * 1e3 : Number.parseInt(end, 10) * 1e3,
requestData.start ? requestData.start * 1e3 : parseInt(start, 10) * 1e3,
requestData.end ? requestData.end * 1e3 : parseInt(end, 10) * 1e3,
);
return {

View File

@@ -98,7 +98,7 @@ function LogsExplorer(): JSX.Element {
setIsLoadingQueries(false);
}, [queryClient]);
const [warning, setWarning] = useState<Warning | undefined>();
const [warning, setWarning] = useState<Warning | undefined>(undefined);
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {

View File

@@ -101,7 +101,7 @@ function TracesExplorer(): JSX.Element {
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
);
const [warning, setWarning] = useState<Warning | undefined>();
const [warning, setWarning] = useState<Warning | undefined>(undefined);
const [isOpen, setOpen] = useState<boolean>(true);
const defaultQuery = useMemo(

View File

@@ -0,0 +1,69 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
// oxlint-disable-next-line no-restricted-imports
useContext,
useState,
} from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import get from 'api/browser/localstorage/get';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
GlobalTimeStoreApi,
} from './globalTimeStore';
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
import { usePersistence } from './usePersistence';
import { useQueryCacheSync } from './useQueryCacheSync';
import { useUrlSync } from './useUrlSync';
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
export function GlobalTimeProvider({
children,
inheritGlobalTime = false,
initialTime,
enableUrlParams = false,
removeQueryParamsOnUnmount = false,
localStoragePersistKey,
refreshInterval: initialRefreshInterval,
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
const parentStore = useContext(GlobalTimeContext);
const globalStore = parentStore ?? defaultGlobalTimeStore;
const resolveInitialTime = (): GlobalTimeSelectedTime => {
if (inheritGlobalTime) {
return globalStore.getState().selectedTime;
}
if (localStoragePersistKey) {
const stored = get(localStoragePersistKey);
if (stored) {
return stored as GlobalTimeSelectedTime;
}
}
return initialTime ?? DEFAULT_TIME_RANGE;
};
// Create isolated store (stable reference)
const [store] = useState(() =>
createGlobalTimeStore({
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
}),
);
useComputedMinMaxSync(store);
useQueryCacheSync(store);
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
usePersistence(store, localStoragePersistKey);
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
}

View File

@@ -0,0 +1,698 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import set from 'api/browser/localstorage/set';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
jest.mock('api/browser/localstorage/set');
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
nuqsProps?: { searchParams?: string },
) => {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('store isolation', () => {
it('should create isolated store for each provider', () => {
const wrapper1 = createWrapper({ initialTime: '1h' });
const wrapper2 = createWrapper({ initialTime: '15m' });
const { result: result1 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper1 },
);
const { result: result2 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper2 },
);
expect(result1.current).toBe('1h');
expect(result2.current).toBe('15m');
});
});
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
});
describe('URL sync', () => {
it('should read relativeTime from URL on mount', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should read custom time from URL on mount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use custom URL keys when provided', async () => {
const wrapper = createWrapper(
{
enableUrlParams: {
relativeTimeKey: 'modalTime',
},
},
{ searchParams: '?modalTime=3h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('3h');
});
});
it('should use custom startTimeKey and endTimeKey when provided', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{
enableUrlParams: {
startTimeKey: 'customStart',
endTimeKey: 'customEnd',
},
},
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should NOT read from URL when enableUrlParams is false', async () => {
const wrapper = createWrapper(
{ enableUrlParams: false, initialTime: '15m' },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Should use initialTime, not URL value
expect(result.current).toBe('15m');
});
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should use startTime/endTime, not relativeTime
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use initialTime when URL has invalid time values', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true, initialTime: '15m' },
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// parseAsInteger returns null for invalid values, so should fallback to initialTime
expect(result.current).toBe('15m');
});
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
// Verify selectedTime is a custom time range string
expect(result.current.selectedTime).toContain('||_||');
});
});
describe('removeQueryParamsOnUnmount', () => {
const createUnmountTestWrapper = (
getQueryString: () => string,
setQueryString: (qs: string) => void,
) => {
return function TestWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={getQueryString()}
onUrlUpdate={(event): void => {
setQueryString(event.queryString);
}}
>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount={false}
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick to ensure cleanup effects would have run
await waitFor(() => {
// URL params should still be present
expect(currentQueryString).toContain('relativeTime=1h');
});
});
it('should remove custom time URL params on unmount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime(), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('startTime');
expect(currentQueryString).toContain('endTime');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should remove custom URL key params on unmount', async () => {
let currentQueryString = 'modalTime=3h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={{
relativeTimeKey: 'modalTime',
}}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('modalTime=3h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('modalTime');
});
});
it('should NOT remove URL params when enableUrlParams is false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={false}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick
await waitFor(() => {
// URL params should still be present (enableUrlParams is false)
expect(currentQueryString).toContain('relativeTime=1h');
});
});
});
});
describe('localStorage persistence', () => {
const mockSet = set as jest.MockedFunction<typeof set>;
beforeEach(() => {
localStorage.clear();
mockSet.mockClear();
mockSet.mockReturnValue(true);
});
it('should read from localStorage on mount', () => {
localStorage.setItem('test-time-key', '6h');
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('6h');
});
it('should write to localStorage on selectedTime change', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-persist-key',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('12h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
});
});
it('should NOT write to localStorage when persistKey is undefined', async () => {
const wrapper = createWrapper({ initialTime: '15m' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('1h');
});
// Wait a tick to ensure any async operations complete
await waitFor(() => {
expect(result.current.selectedTime).toBe('1h');
});
expect(mockSet).not.toHaveBeenCalled();
});
it('should only write to localStorage when selectedTime changes, not other state', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
// Change refreshInterval (not selectedTime)
act(() => {
result.current.setRefreshInterval(5000);
});
// Wait to ensure subscription handler had a chance to run
await waitFor(() => {
expect(result.current.refreshInterval).toBe(5000);
});
// Should NOT have written to localStorage for refreshInterval change
expect(mockSet).not.toHaveBeenCalled();
// Now change selectedTime
act(() => {
result.current.setSelectedTime('1h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
});
});
it('should fallback to initialTime when localStorage contains empty string', () => {
localStorage.setItem('test-key', '');
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Empty string is falsy, should use initialTime
expect(result.current).toBe('15m');
});
it('should write custom time range to localStorage', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-custom-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
});
});
});
describe('refreshInterval', () => {
it('should initialize with provided refreshInterval', () => {
const wrapper = createWrapper({ refreshInterval: 5000 });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.refreshInterval).toBe(5000);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,83 @@
import { act } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, defaultGlobalTimeStore } from '../globalTimeStore';
import { createCustomTimeRange } from '../utils';
describe('createGlobalTimeStore', () => {
describe('factory function', () => {
it('should create independent store instances', () => {
const store1 = createGlobalTimeStore();
const store2 = createGlobalTimeStore();
store1.getState().setSelectedTime('1h');
expect(store1.getState().selectedTime).toBe('1h');
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should accept initial state', () => {
const store = createGlobalTimeStore({
selectedTime: '15m',
refreshInterval: 5000,
});
expect(store.getState().selectedTime).toBe('15m');
expect(store.getState().refreshInterval).toBe(5000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should compute isRefreshEnabled correctly for custom time', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({
selectedTime: customTime,
refreshInterval: 5000,
});
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('defaultGlobalTimeStore', () => {
it('should be a singleton', () => {
expect(defaultGlobalTimeStore).toBeDefined();
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
});
});
describe('setRefreshInterval', () => {
it('should update refresh interval and enable refresh', () => {
const store = createGlobalTimeStore();
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should disable refresh when interval is 0', () => {
const store = createGlobalTimeStore({ refreshInterval: 5000 });
act(() => {
store.getState().setRefreshInterval(0);
});
expect(store.getState().refreshInterval).toBe(0);
expect(store.getState().isRefreshEnabled).toBe(false);
});
it('should not enable refresh for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({ selectedTime: customTime });
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
});

View File

@@ -1,204 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const {
minTime: resultMin,
maxTime: resultMax,
} = result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,789 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
/**
* Creates an isolated store wrapper for testing.
* Each test gets its own store instance, avoiding test pollution.
*/
function createIsolatedWrapper(
initialState?: Partial<GlobalTimeState>,
): ({ children }: { children: ReactNode }) => JSX.Element {
const store = createGlobalTimeStore(initialState);
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
};
}
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
it('should have lastRefreshTimestamp as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
it('should reset lastComputedMinMax when selectedTime changes', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and store initial values
act(() => {
result.current.computeAndStoreMinMax();
});
// Verify we have cached values
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
// Now switch to a custom time range
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
// lastComputedMinMax should be reset
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
it('should return fresh custom time values after switching from relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and cache values for relative time
act(() => {
result.current.computeAndStoreMinMax();
});
const relativeMinMax = { ...result.current.lastComputedMinMax };
// Switch to custom time range
const customMinTime = 5000000000;
const customMaxTime = 6000000000;
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
// getMinMaxTime should return the custom time values, not cached relative values
const returned = result.current.getMinMaxTime();
expect(returned.minTime).toBe(customMinTime);
expect(returned.maxTime).toBe(customMaxTime);
expect(returned).not.toStrictEqual(relativeMinMax);
jest.useRealTimers();
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should NOT round custom time range passed as parameter', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Store is set to relative time
act(() => {
result.current.setSelectedTime('15m');
});
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
// Pass custom time as parameter (different from store's selectedTime)
const { minTime, maxTime } = result.current.getMinMaxTime(customTime);
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return same values on subsequent calls for relative time under a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(59000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime);
expect(second.minTime).toBe(first.minTime);
});
it('should return different values on subsequent calls for relative time only after a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(60000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
});
it('should return stored lastComputedMinMax when available', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Advance time by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return stored values, not fresh computation
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(stored);
});
it('should compute fresh values when different selectedTime is provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Request time for a different selectedTime
const freshValues = result.current.getMinMaxTime('1h');
// Should NOT equal stored values (different duration)
expect(freshValues).not.toStrictEqual(stored);
});
it('should behave same as no-param call when selectedTime matches state', () => {
// This tests the pattern used in K8sBaseDetails:
// getMinMaxTime(selectedTime) where selectedTime === state.selectedTime
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000, // isRefreshEnabled = true
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call with selectedTime parameter that matches state.selectedTime
// Should behave the same as calling without parameter
const withParam = result.current.getMinMaxTime('15m');
const withoutParam = result.current.getMinMaxTime();
expect(withParam).toStrictEqual(withoutParam);
expect(withParam.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
describe('with isRefreshEnabled (isolated store)', () => {
it('should compute fresh values when isRefreshEnabled is true', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return fresh values, not cached
const freshValues = result.current.getMinMaxTime();
expect(freshValues.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(freshValues.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastComputedMinMax when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update lastComputedMinMax
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(result.current.lastComputedMinMax.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastRefreshTimestamp when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update timestamp
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
it('should NOT update lastComputedMinMax when values have not changed (same minute)', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time but stay within same minute
act(() => {
jest.advanceTimersByTime(30000);
});
// Call getMinMaxTime - should NOT update store (same minute boundary)
act(() => {
result.current.getMinMaxTime();
});
// Values should be unchanged (no unnecessary re-renders)
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should return cached values when isRefreshEnabled is false', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Refresh disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const storedMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return cached values since refresh is disabled
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(storedMinMax);
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
});
it('should return same values for custom time range regardless of time passing', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const wrapper = createIsolatedWrapper({
selectedTime: customTime,
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// isRefreshEnabled should be false for custom time ranges
expect(result.current.isRefreshEnabled).toBe(false);
// Custom time ranges always return the fixed values, not relative to "now"
const first = result.current.getMinMaxTime();
expect(first.minTime).toBe(minTime);
expect(first.maxTime).toBe(maxTime);
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Should still return the same fixed values (custom range doesn't drift)
const second = result.current.getMinMaxTime();
expect(second.minTime).toBe(minTime);
expect(second.maxTime).toBe(maxTime);
});
it('should handle multiple consecutive refetch intervals correctly', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Simulate 3 refetch intervals crossing minute boundaries
for (let i = 1; i <= 3; i++) {
act(() => {
jest.advanceTimersByTime(60000);
});
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + i * 60000 * NANO_SECOND_MULTIPLIER,
);
}
});
});
});
describe('computeAndStoreMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store rounded min/max values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.computeAndStoreMinMax();
});
// maxTime should be rounded to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should update lastRefreshTimestamp', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const beforeTimestamp = Date.now();
act(() => {
result.current.computeAndStoreMinMax();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
beforeTimestamp,
);
});
it('should return the computed values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return exact values, NOT rounded values
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
// lastComputedMinMax should also have exact values
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
});
});
describe('updateRefreshTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should update lastRefreshTimestamp to current time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.updateRefreshTimestamp();
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should not modify lastComputedMinMax', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.computeAndStoreMinMax();
});
const beforeMinMax = { ...result.current.lastComputedMinMax };
act(() => {
jest.advanceTimersByTime(5000);
result.current.updateRefreshTimestamp();
});
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,122 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from '../hooks';
import { createCustomTimeRange } from '../utils';
describe('useGlobalTime', () => {
it('should return full store state without selector', () => {
const { result } = renderHook(() => useGlobalTime());
expect(result.current.selectedTime).toBeDefined();
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
});
it('should return selected value with selector', () => {
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
expect(typeof result.current).toBe('string');
});
it('should use context store when provided', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('1h');
});
});
describe('useIsCustomTimeRange', () => {
it('should return false for relative time', () => {
const { result } = renderHook(() => useIsCustomTimeRange());
expect(result.current).toBe(false);
});
it('should return true for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
const { result } = renderHook(() => useIsCustomTimeRange(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toBe(true);
});
});
describe('useGlobalTimeStoreApi', () => {
it('should return store API', () => {
const { result } = renderHook(() => useGlobalTimeStoreApi());
expect(result.current.getState).toBeInstanceOf(Function);
expect(result.current.subscribe).toBeInstanceOf(Function);
});
});
describe('useLastComputedMinMax', () => {
it('should return lastComputedMinMax from store', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Compute the min/max first
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toStrictEqual(contextStore.getState().lastComputedMinMax);
});
it('should update when store changes', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
const firstValue = { ...result.current };
// Change time and recompute
act(() => {
jest.advanceTimersByTime(60000); // Advance 1 minute
contextStore.getState().computeAndStoreMinMax();
});
expect(result.current).not.toStrictEqual(firstValue);
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,279 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useGlobalTimeQueryInvalidate', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should return a function', () => {
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
expect(typeof result.current).toBe('function');
});
it('should call computeAndStoreMinMax before invalidating queries', async () => {
const wrapper = createWrapper(
{ initialTime: '15m', refreshInterval: 5000 },
queryClient,
);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Initial computation
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// lastComputedMinMax should have been updated
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up a query with AUTO_REFRESH_QUERY key
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: mockQueryFn,
}),
{ wrapper },
);
// Wait for initial query to complete
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(mockQueryFn).toHaveBeenCalledTimes(1);
// Now render the invalidate hook and call it
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Query should have been refetched
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
});
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up both types of queries
const { result: autoRefreshQuery } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
queryFn: autoRefreshQueryFn,
}),
{ wrapper },
);
const { result: regularQuery } = renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: regularQueryFn,
}),
{ wrapper },
);
// Wait for initial queries to complete
await waitFor(() => {
expect(autoRefreshQuery.current.isSuccess).toBe(true);
expect(regularQuery.current.isSuccess).toBe(true);
});
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
expect(regularQueryFn).toHaveBeenCalledTimes(1);
// Call invalidate
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Only auto-refresh query should be refetched
await waitFor(() => {
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
});
// Regular query should NOT be refetched
expect(regularQueryFn).toHaveBeenCalledTimes(1);
});
it('should use exact custom time values (not rounded) when invalidating', async () => {
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// Verify custom time values are NOT rounded
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
minTimeWithSeconds,
);
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
maxTimeWithSeconds,
);
});
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
queryFn: queryFn3,
}),
{ wrapper },
);
// Wait for initial queries
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
expect(queryFn3).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// All queries should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
expect(queryFn3).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,229 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useIsGlobalTimeQueryRefreshing', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
});
afterEach(() => {
queryClient.clear();
});
it('should return false when no queries are fetching', () => {
const wrapper = createWrapper(queryClient);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
expect(result.current).toBe(false);
});
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start the auto-refresh query
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveQuery({ data: 'done' });
});
// Should be false after fetching completes
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start a regular query (not auto-refresh)
renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - not an auto-refresh query
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveQuery({ data: 'done' });
});
});
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
let resolveQuery1: (value: unknown) => void;
let resolveQuery2: (value: unknown) => void;
const queryPromise1 = new Promise((resolve) => {
resolveQuery1 = resolve;
});
const queryPromise2 = new Promise((resolve) => {
resolveQuery2 = resolve;
});
const wrapper = createWrapper(queryClient);
// Start multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: () => queryPromise1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: () => queryPromise2,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve first query
act(() => {
resolveQuery1({ data: 'done1' });
});
// Should still be true (second query still fetching)
await waitFor(() => {
expect(result.current).toBe(true);
});
// Resolve second query
act(() => {
resolveQuery2({ data: 'done2' });
});
// Should be false after all complete
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
let resolveAutoRefresh: (value: unknown) => void;
let resolveRegular: (value: unknown) => void;
const autoRefreshPromise = new Promise((resolve) => {
resolveAutoRefresh = resolve;
});
const regularPromise = new Promise((resolve) => {
resolveRegular = resolve;
});
const wrapper = createWrapper(queryClient);
// Start both types of queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
queryFn: () => autoRefreshPromise,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: ['regular'],
queryFn: () => regularPromise,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true (auto-refresh is fetching)
expect(result.current).toBe(true);
// Resolve auto-refresh query
act(() => {
resolveAutoRefresh({ data: 'done' });
});
// Should be false even though regular query is still fetching
await waitFor(() => {
expect(result.current).toBe(false);
});
// Cleanup
act(() => {
resolveRegular({ data: 'done' });
});
});
});

View File

@@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
import { useQueryCacheSync } from '../useQueryCacheSync';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function createWrapper(
queryClient: QueryClient,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useQueryCacheSync', () => {
let store: GlobalTimeStoreApi;
let queryClient: QueryClient;
beforeEach(() => {
store = createGlobalTimeStore();
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
// Initialize store
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(5000);
});
// Render the hook
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a successful auto-refresh query
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should not update timestamp for non-auto-refresh queries', async () => {
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a regular query (not auto-refresh)
await act(async () => {
await queryClient.fetchQuery({
queryKey: ['some-other-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
});

View File

@@ -1,10 +1,15 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from '../utils';
describe('globalTime/utils', () => {
@@ -59,7 +64,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
const result = parseCustomTimeRange(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return null for non-custom time range strings', () => {
@@ -75,7 +80,7 @@ describe('globalTime/utils', () => {
it('should handle zero values', () => {
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
expect(result).toEqual({ minTime: 0, maxTime: 0 });
expect(result).toStrictEqual({ minTime: 0, maxTime: 0 });
});
});
@@ -94,7 +99,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = createCustomTimeRange(minTime, maxTime);
const result = parseSelectedTime(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return fallback for invalid custom time range', () => {
@@ -136,4 +141,130 @@ describe('globalTime/utils', () => {
expect(result.minTime).toBe(now - oneDayNs);
});
});
describe('roundDownToMinute', () => {
it('should round down timestamp to minute boundary', () => {
// 2024-01-15T12:30:45.123Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321845123 * NANO_SECOND_MULTIPLIER; // 12:30:45.123
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
it('should not change timestamp already at minute boundary', () => {
const inputNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(inputNano);
});
it('should handle timestamp at 59 seconds', () => {
// 2024-01-15T12:30:59.999Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321859999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
});
describe('computeRoundedMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return rounded maxTime for relative time', () => {
const result = computeRoundedMinMax('15m');
// maxTime should be rounded down to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(expectedMaxTime);
});
it('should compute minTime based on rounded maxTime', () => {
const result = computeRoundedMinMax('15m');
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
});
it('should return unchanged values for custom time range', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const result = computeRoundedMinMax(customTime);
expect(result.minTime).toBe(minTime);
expect(result.maxTime).toBe(maxTime);
});
it('should return fallback for invalid custom time range', () => {
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
const result = computeRoundedMinMax(invalidCustom);
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fallbackDuration = 30 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fallbackDuration);
});
});
describe('getAutoRefreshQueryKey', () => {
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
});
it('should append selectedTime at end', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no additional query parts', () => {
const result = getAutoRefreshQueryKey('1h');
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
it('should handle custom time range as selectedTime', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'METRICS',
customTime,
]);
});
it('should handle object query parts', () => {
const params = { entityId: '123', filter: 'active' };
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'ENTITY',
params,
'15m',
]);
});
});
});

View File

@@ -1,32 +1,138 @@
import { createStore, StoreApi, useStore } from 'zustand';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
import {
computeRoundedMinMax,
isCustomTimeRange,
parseSelectedTime,
} from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
): boolean {
if (isCustomTimeRange(selectedTime)) {
return false;
}
return refreshInterval > 0;
}
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
return createStore<GlobalTimeStore>((set, get) => ({
selectedTime,
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,
newRefreshInterval?: number,
): void => {
set((state) => {
const interval = newRefreshInterval ?? state.refreshInterval;
return {
selectedTime: time,
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
// Reset cached values so getMinMaxTime computes fresh values for the new selection
lastComputedMinMax: { minTime: 0, maxTime: 0 },
};
});
},
setRefreshInterval: (interval: number): void => {
set((state) => ({
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
}));
},
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime): ParsedTimeRange => {
const state = get();
const timeToUse = selectedTime ?? state.selectedTime;
// For custom time ranges, return exact values without rounding
if (isCustomTimeRange(timeToUse)) {
return parseSelectedTime(timeToUse);
}
if (selectedTime && selectedTime !== state.selectedTime) {
return computeRoundedMinMax(selectedTime);
}
// When auto-refresh is enabled, compute fresh values and update store
// This ensures time moves forward on each refetchInterval cycle
// Note: computeRoundedMinMax rounds to minute boundaries, so all queries
// calling getMinMaxTime within the same minute get consistent values
if (state.isRefreshEnabled) {
const freshMinMax = computeRoundedMinMax(state.selectedTime);
// Only update store if values changed (avoids unnecessary re-renders)
if (
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
) {
set({
lastComputedMinMax: freshMinMax,
lastRefreshTimestamp: Date.now(),
});
}
return freshMinMax;
}
// Return stored values if they exist (set by computeAndStoreMinMax)
// This ensures all callers get the same values within a refresh cycle
if (state.lastComputedMinMax.maxTime > 0) {
return state.lastComputedMinMax;
}
return computeRoundedMinMax(state.selectedTime);
},
computeAndStoreMinMax: (): ParsedTimeRange => {
const { selectedTime } = get();
// For custom time ranges, use exact values without rounding
const computedMinMax = isCustomTimeRange(selectedTime)
? parseSelectedTime(selectedTime)
: computeRoundedMinMax(selectedTime);
set({
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
return computedMinMax;
},
updateRefreshTimestamp: (): void => {
set({ lastRefreshTimestamp: Date.now() });
},
}));
}
export const defaultGlobalTimeStore = createGlobalTimeStore();
export const useGlobalTimeStore = <T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
): T => {
return useStore(
defaultGlobalTimeStore,
selector ?? ((state) => state as unknown as T),
);
};

View File

@@ -0,0 +1,58 @@
// oxlint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { GlobalTimeContext } from './GlobalTimeContext';
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeStore, ParsedTimeRange } from './types';
import { isCustomTimeRange } from './utils';
/**
* Access global time state with optional selector for performance.
*
* @example
* // Full state (re-renders on any change)
* const { selectedTime, setSelectedTime } = useGlobalTime();
*
* @example
* // With selector (re-renders only when selectedTime changes)
* const selectedTime = useGlobalTime(state => state.selectedTime);
*/
export function useGlobalTime<T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const contextStore = useContext(GlobalTimeContext);
const store = contextStore ?? defaultGlobalTimeStore;
return useStoreWithEqualityFn(
store,
selector ?? ((state) => state as unknown as T),
equalityFn,
);
}
/**
* Check if currently using a custom time range.
*/
export function useIsCustomTimeRange(): boolean {
const selectedTime = useGlobalTime((state) => state.selectedTime);
return isCustomTimeRange(selectedTime);
}
/**
* Get the store API directly (for subscriptions or non-React contexts).
*/
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
const contextStore = useContext(GlobalTimeContext);
return contextStore ?? defaultGlobalTimeStore;
}
/**
* Get the last computed min/max time values.
* Use this for display purposes to ensure consistency with query data.
*/
export function useLastComputedMinMax(): ParsedTimeRange {
return useGlobalTime((state) => state.lastComputedMinMax);
}

View File

@@ -1,9 +1,526 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
/**
* # Global Time Store
*
* Centralized time management for the application with auto-refresh support.
*
* ## Quick Start
*
* ```tsx
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MyComponent() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* const { data } = useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return fetchData({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* }
* ```
*
* ## Core Concepts
*
* ### Time Formats
*
* | Format | Example | Description |
* |--------|---------|-------------|
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
*
* ### Time Units
*
* - Store values are in **nanoseconds**
* - Most APIs expect **seconds**
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
*
* ## Integration Guide
*
* ### Step 1: Get Store State
*
* Use selectors for optimal re-render performance:
*
* ```tsx
* // Good - only re-renders when selectedTime changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
*
* // Avoid - re-renders on ANY store change
* const store = useGlobalTime();
* ```
*
* ### Step 2: Build Query Key
*
* Always use `getAutoRefreshQueryKey` to enable auto-refresh:
*
* ```tsx
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(
* selectedTime, // Required - triggers invalidation
* 'UNIQUE_KEY', // Your query identifier
* ...otherParams // Additional cache-busting params
* ),
* [selectedTime, ...deps]
* );
* ```
*
* ### Step 3: Fetch Data
*
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
*
* ```tsx
* const { data } = useQuery({
* queryKey,
* queryFn: () => {
* // Fresh time values computed here during auto-refresh
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return api.fetch({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ### Step 4: Add Refresh Button (Optional)
*
* ```tsx
* import {
* useGlobalTimeQueryInvalidate,
* useIsGlobalTimeQueryRefreshing,
* } from 'store/globalTime';
*
* function RefreshButton() {
* const invalidate = useGlobalTimeQueryInvalidate();
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
*
* return (
* <button onClick={invalidate} disabled={isRefreshing}>
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
* </button>
* );
* }
* ```
*
* ## Avoiding Stale Data
*
* ### Problem: Time Drift During Refresh
*
* If multiple queries compute time independently, they may use different values:
*
* ```tsx
* // BAD - each query gets different time
* queryFn: () => {
* const now = Date.now();
* return fetchData({ end: now, start: now - duration });
* }
* ```
*
* ### Solution: Use getMinMaxTime()
*
* `getMinMaxTime()` ensures all queries use consistent timestamps:
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
* - When auto-refresh is **enabled**: computes fresh values (rounded to minute boundaries)
*
* Since values are rounded to minute boundaries, all queries calling `getMinMaxTime()`
* within the same minute get identical timestamps.
*
* ```tsx
* // GOOD - all queries get same time
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* return fetchData({ start: minTime, end: maxTime });
* }
* ```
*
* ### How It Works
*
* **Manual refresh:**
* 1. User clicks refresh
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
* 3. Fresh min/max stored in `lastComputedMinMax`
* 4. All queries re-run and call `getMinMaxTime()`
* 5. All get the SAME cached values
*
* **Auto-refresh (when `isRefreshEnabled = true`):**
* 1. React-query's `refetchInterval` triggers query re-execution
* 2. `getMinMaxTime()` computes fresh values (rounded to minute)
* 3. If values changed, updates `lastComputedMinMax` cache
* 4. All queries within same minute get consistent values
*
* ## Auto-Refresh Setup
*
* Auto-refresh is enabled when:
* - `selectedTime` is a relative duration (e.g., `'15m'`)
* - `refreshInterval > 0`
*
* ```tsx
* // Auto-refresh configuration
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
* queryFn: () => { ... },
* // Enable periodic refetch
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ## API Reference
*
* ### Hooks
*
* | Hook | Returns | Description |
* |------|---------|-------------|
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
*
* ### Store Actions
*
* | Action | Description |
* |--------|-------------|
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
*
* ### Utilities
*
* | Function | Description |
* |----------|-------------|
* | `getAutoRefreshQueryKey(time, ...parts)` | Build query key with auto-refresh support |
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
* | `isCustomTimeRange(time)` | Check if time is custom range format |
* | `createCustomTimeRange(min, max)` | Create custom range string |
*
* ### Constants
*
* | Constant | Value | Description |
* |----------|-------|-------------|
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
*
* ## Context & Composition
*
* ### Why Use Context?
*
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
* to create isolated time state for specific UI sections (modals, drawers, etc.).
*
* ### Provider Options
*
* | Option | Type | Description |
* |--------|------|-------------|
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
*
* ### Example 1: Isolated Time in Modal
*
* A modal with its own time picker that doesn't affect the main page:
*
* ```tsx
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
*
* function EntityDetailsModal({ entity, onClose }) {
* return (
* <Modal open onClose={onClose}>
* // Isolated time context - changes here don't affect parent
* <GlobalTimeProvider
* inheritGlobalTime // Start with parent's current time
* refreshInterval={0} // No auto-refresh in modal
* >
* <ModalContent entity={entity} />
* </GlobalTimeProvider>
* </Modal>
* );
* }
*
* function ModalContent({ entity }) {
* // This useGlobalTime reads from the modal's isolated store
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
*
* return (
* <>
* <DateTimePicker
* value={selectedTime}
* onChange={(time) => setSelectedTime(time)}
* />
* <EntityMetrics entity={entity} />
* <EntityLogs entity={entity} />
* </>
* );
* }
* ```
*
* ### Example 2: List Page with Detail Drawer
*
* Main list uses global time, drawer has independent time:
*
* ```tsx
* // Main list page - uses global time (no provider needed)
* function K8sPodsList() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const [selectedPod, setSelectedPod] = useState(null);
*
* return (
* <>
* <PageHeader>
* <DateTimeSelectionV3 /> // Controls global time
* </PageHeader>
*
* <PodsTable
* timeRange={selectedTime}
* onRowClick={setSelectedPod}
* />
*
* {selectedPod && (
* <PodDetailsDrawer
* pod={selectedPod}
* onClose={() => setSelectedPod(null)}
* />
* )}
* </>
* );
* }
*
* // Drawer with its own time context
* function PodDetailsDrawer({ pod, onClose }) {
* return (
* <Drawer open onClose={onClose}>
* <GlobalTimeProvider
* inheritGlobalTime // Start with list's time
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
* enableUrlParams={{
* relativeTimeKey: 'drawerTime',
* startTimeKey: 'drawerStart',
* endTimeKey: 'drawerEnd',
* }}
* >
* <DrawerHeader>
* <DateTimeSelectionV3 /> // Controls drawer's time only
* </DrawerHeader>
*
* <Tabs>
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
* </Tabs>
* </GlobalTimeProvider>
* </Drawer>
* );
* }
* ```
*
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
* ```tsx
* // App level - global time
* function App() {
* return (
* <QueryClientProvider>
* // No provider here = uses defaultGlobalTimeStore
* <Dashboard />
* </QueryClientProvider>
* );
* }
*
* // Dashboard with comparison panel
* function Dashboard() {
* return (
* <div className="dashboard">
* // Main dashboard uses global time
* <MainCharts />
*
* // Comparison panel has its own time
* <GlobalTimeProvider initialTime="1h">
* <ComparisonPanel />
* </GlobalTimeProvider>
* </div>
* );
* }
*
* function ComparisonPanel() {
* // This reads from ComparisonPanel's isolated store (1h)
* // Not affected by global time changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* return <ComparisonCharts timeRange={selectedTime} />;
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
* return (
* <GlobalTimeProvider
* enableUrlParams={{
* relativeTimeKey: 'time', // ?time=15m
* startTimeKey: 'startTime', // ?startTime=1234567890
* endTimeKey: 'endTime', // ?endTime=1234567899
* }}
* initialTime="15m" // Fallback if URL has no time params
* >
* <TracesContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*
* ```tsx
* function MetricsExplorer() {
* return (
* <GlobalTimeProvider
* localStoragePersistKey="metrics-explorer-time"
* initialTime="1h" // Fallback for first visit
* >
* <MetricsContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Context Resolution Order
*
* When `useGlobalTime()` is called, it resolves the store in this order:
*
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
* 2. `defaultGlobalTimeStore` (global singleton)
*
* ```
* App (no provider -> uses defaultGlobalTimeStore)
* |-- Dashboard
* |-- MainCharts (uses defaultGlobalTimeStore)
* |-- GlobalTimeProvider (isolated store A)
* |-- ComparisonPanel (uses store A)
* |-- GlobalTimeProvider (isolated store B)
* |-- NestedChart (uses store B)
* ```
*
* ## Complete Example
*
* ```tsx
* import { useMemo } from 'react';
* import { useQuery } from 'react-query';
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MetricsPanel({ entityId }: { entityId: string }) {
* // 1. Get store state with selectors
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* // 2. Build query key (memoized)
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
* [selectedTime, entityId]
* );
*
* // 3. Query with auto-refresh
* const { data, isLoading } = useQuery({
* queryKey,
* queryFn: () => {
* // Get fresh time inside queryFn
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
*
* return fetchMetrics({ entityId, start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
*
* return <Chart data={data} loading={isLoading} />;
* }
* ```
*
* @module store/globalTime
*/
// Store
export {
createGlobalTimeStore,
defaultGlobalTimeStore,
useGlobalTimeStore,
} from './globalTimeStore';
export type { GlobalTimeStoreApi } from './globalTimeStore';
// Context & Provider
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
// Hooks
export {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from './hooks';
// Query hooks for auto-refresh
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
// Types
export type {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeActions,
GlobalTimeProviderOptions,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
// Utilities
export {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from './utils';
// Internal hooks (for advanced use cases)
export { useQueryCacheSync } from './useQueryCacheSync';

View File

@@ -50,3 +50,52 @@ export interface IGlobalTimeStoreActions {
*/
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
}
export interface GlobalTimeProviderOptions {
/** Initialize from parent/global time */
inheritGlobalTime?: boolean;
/** Initial time if not inheriting */
initialTime?: GlobalTimeSelectedTime;
/** URL sync configuration. When false/omitted, no URL sync. */
enableUrlParams?:
| boolean
| {
relativeTimeKey?: string;
startTimeKey?: string;
endTimeKey?: string;
};
removeQueryParamsOnUnmount?: boolean;
localStoragePersistKey?: string;
refreshInterval?: number;
}
export interface GlobalTimeState {
selectedTime: GlobalTimeSelectedTime;
refreshInterval: number;
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
}
export interface GlobalTimeActions {
setSelectedTime: (
time: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
setRefreshInterval: (interval: number) => void;
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime) => ParsedTimeRange;
/**
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
* Call this before invalidating queries to ensure all queries use the same time values.
*
* @returns The newly computed ParsedTimeRange
*/
computeAndStoreMinMax: () => ParsedTimeRange;
/**
* Update the refresh timestamp to current time.
* Called by QueryCache listener when auto-refresh queries complete.
*/
updateRefreshTimestamp: () => void;
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
useEffect(() => {
store.getState().computeAndStoreMinMax();
}, [store]);
useEffect(() => {
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime !== previousSelectedTime) {
previousSelectedTime = state.selectedTime;
store.getState().computeAndStoreMinMax();
}
});
}, [store]);
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGlobalTime } from './hooks';
/**
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*
* This hook computes fresh time values before invalidating queries,
* ensuring all queries use the same min/max time during a refresh cycle.
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
return useCallback(async () => {
// Compute fresh time values BEFORE invalidating
// This ensures all queries that re-run will use the same time values
computeAndStoreMinMax();
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient, computeAndStoreMinMax]);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import set from 'api/browser/localstorage/set';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function usePersistence(
store: GlobalTimeStoreApi,
persistKey: string | undefined,
): void {
useEffect(() => {
if (!persistKey) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
set(persistKey, state.selectedTime);
});
}, [store, persistKey]);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Subscribes to QueryCache events and updates the store's lastRefreshTimestamp
* when auto-refresh queries complete successfully.
*/
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
const queryClient = useQueryClient();
useEffect(() => {
const queryCache = queryClient.getQueryCache();
return queryCache.subscribe((event) => {
// Only react to successful query updates
if (event?.type !== 'queryUpdated') {
return;
}
const action = event.action as { type?: string };
if (action?.type !== 'success') {
return;
}
// Check if it's an auto-refresh query by key prefix
const queryKey = event.query.queryKey;
if (
!Array.isArray(queryKey) ||
queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY
) {
return;
}
// Update the refresh timestamp in store
store.getState().updateRefreshTimestamp();
});
}, [queryClient, store]);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeProviderOptions } from './types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
} from './utils';
interface UrlSyncConfig {
relativeTimeKey: string;
startTimeKey: string;
endTimeKey: string;
}
export function useUrlSync(
store: GlobalTimeStoreApi,
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
removeOnUnmount: boolean,
): void {
const isInitialMount = useRef(true);
const keys: UrlSyncConfig =
enableUrlParams && typeof enableUrlParams === 'object'
? {
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
}
: {
relativeTimeKey: 'relativeTime',
startTimeKey: 'startTime',
endTimeKey: 'endTime',
};
const [urlState, setUrlState] = useQueryStates(
{
[keys.relativeTimeKey]: parseAsString,
[keys.startTimeKey]: parseAsInteger,
[keys.endTimeKey]: parseAsInteger,
},
{ history: 'replace' },
);
useEffect(() => {
if (!enableUrlParams || !isInitialMount.current) {
return;
}
isInitialMount.current = false;
const relativeTime = urlState[keys.relativeTimeKey];
const startTime = urlState[keys.startTimeKey];
const endTime = urlState[keys.endTimeKey];
if (typeof startTime === 'number' && typeof endTime === 'number') {
const customTime = createCustomTimeRange(
startTime * NANO_SECOND_MULTIPLIER,
endTime * NANO_SECOND_MULTIPLIER,
);
store.getState().setSelectedTime(customTime);
} else if (relativeTime) {
store.getState().setSelectedTime(relativeTime as Time);
}
}, [
urlState,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
store,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
// Only update URL when selectedTime actually changes
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
if (isCustomTimeRange(state.selectedTime)) {
const parsed = parseCustomTimeRange(state.selectedTime);
if (parsed) {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
});
}
} else {
void setUrlState({
[keys.relativeTimeKey]: state.selectedTime,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
}
});
}, [
store,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
setUrlState,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams || !removeOnUnmount) {
return;
}
return (): void => {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
};
}, [
removeOnUnmount,
keys?.relativeTimeKey,
keys?.startTimeKey,
keys?.endTimeKey,
setUrlState,
enableUrlParams,
]);
}

View File

@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
const minTime = Number.parseInt(minStr, 10);
const maxTime = Number.parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
@@ -87,3 +87,54 @@ export function getAutoRefreshQueryKey(
): unknown[] {
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}
/**
* Round timestamp down to the nearest minute boundary.
* Used to ensure consistent time values across multiple consumers within the same minute.
*
* @param timestampNano - Timestamp in nanoseconds
* @returns Timestamp rounded down to minute boundary in nanoseconds
*/
export function roundDownToMinute(timestampNano: number): number {
const msPerMinute = 60 * 1000;
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
const roundedMs = Math.floor(timestampMs / msPerMinute) * msPerMinute;
return roundedMs * NANO_SECOND_MULTIPLIER;
}
/**
* Compute min/max time with maxTime rounded down to minute boundary.
* For relative times, this ensures all calls within the same minute return identical values.
* For custom time ranges, returns the stored values unchanged.
*
* @param selectedTime - The selected time (relative like '15m' or custom range)
* @returns ParsedTimeRange with rounded maxTime for relative times
*/
export function computeRoundedMinMax(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
const parsed = parseCustomTimeRange(selectedTime);
if (parsed) {
return parsed;
}
// Fallback if parsing fails
const now = Date.now() * NANO_SECOND_MULTIPLIER;
return { minTime: now - fallbackDurationInNanoSeconds, maxTime: now };
}
// For relative time, compute with rounded maxTime
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
const roundedMaxTime = roundDownToMinute(nowNano);
// Get the duration from the relative time
const { minTime: originalMin, maxTime: originalMax } = getMinMaxForSelectedTime(
selectedTime as Time,
0,
0,
);
const durationNano = originalMax - originalMin;
return {
minTime: roundedMaxTime - durationNano,
maxTime: roundedMaxTime,
};
}

View File

@@ -23,14 +23,7 @@ import (
"sync"
"time"
htmltemplate "html/template"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -45,30 +38,16 @@ const (
// Email implements a Notifier for email notifications.
type Email struct {
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
templater alertmanagertypes.Templater
templateStore emailtypes.TemplateStore
}
// layoutData is the value passed to the alert_email_notification layout
// template. It embeds NotificationTemplateData so templates can reference
// `.Alert.Status`, `.Alert.TotalFiring`, `.Alert.TotalResolved`,
// `.NotificationTemplateData.ExternalURL`, etc. alongside the rendered
// Title and per-alert Bodies.
type layoutData struct {
alertmanagertypes.NotificationTemplateData
Title string
Bodies []htmltemplate.HTML
conf *config.EmailConfig
tmpl *template.Template
logger *slog.Logger
hostname string
}
var errNoAuthUsernameConfigured = errors.NewInternalf(errors.CodeInternal, "no auth username configured")
// New returns a new Email notifier. templateStore may be nil; in that case
// custom-body alerts fall back to plain <div>-wrapped HTML.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, templateStore emailtypes.TemplateStore) *Email {
// New returns a new Email notifier.
func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email {
if _, ok := c.Headers["Subject"]; !ok {
c.Headers["Subject"] = config.DefaultEmailSubject
}
@@ -84,7 +63,7 @@ func New(c *config.EmailConfig, t *template.Template, l *slog.Logger, templater
if err != nil {
h = "localhost.localdomain"
}
return &Email{conf: c, tmpl: t, logger: l, hostname: h, templater: templater, templateStore: templateStore}
return &Email{conf: c, tmpl: t, logger: l, hostname: h}
}
// auth resolves a string of authentication mechanisms.
@@ -266,16 +245,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
// Prepare the content for the email. customSubject, when non-empty, overrides
// the configured Subject header for this notification only. We deliberately
// do not mutate n.conf.Headers here: the config map is shared across
// concurrent notifications to the same receiver.
customSubject, htmlBody, err := n.prepareContent(ctx, as, data)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// Send the email headers and body.
message, err := c.Data()
if err != nil {
@@ -293,10 +262,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
buffer := &bytes.Buffer{}
for header, t := range n.conf.Headers {
if header == "Subject" && customSubject != "" {
fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", customSubject))
continue
}
value, err := n.tmpl.ExecuteTextString(t, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute %q header template", header)
@@ -371,7 +336,7 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
}
}
if htmlBody != "" {
if len(n.conf.HTML) > 0 {
// Html template
// Preferred alternative placed last per section 5.1.4 of RFC 2046
// https://www.ietf.org/rfc/rfc2046.txt
@@ -382,8 +347,12 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "create part for html template")
}
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
}
qw := quotedprintable.NewWriter(w)
_, err = qw.Write([]byte(htmlBody))
_, err = qw.Write([]byte(body))
if err != nil {
return true, errors.WrapInternalf(err, errors.CodeInternal, "write HTML part")
}
@@ -412,146 +381,6 @@ func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
return false, nil
}
// prepareContent returns a subject override (empty when the default config
// Subject should be used) and the HTML body for the email. Callers must treat
// the subject as local state and never write it back to n.conf.Headers.
func (n *Email) prepareContent(ctx context.Context, alerts []*types.Alert, data *template.Data) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Headers["Subject"],
// The email body's default path uses the configured HTML template
// verbatim (n.conf.HTML below). Leave DefaultBodyTemplate empty so
// the templater produces a single empty default body we ignore.
DefaultBodyTemplate: "",
}, alerts)
if err != nil {
return "", "", err
}
// subject == "" signals "use the configured Subject header"; Notify then
// runs it through the normal header template pipeline.
subject := ""
if customTitle != "" {
subject = result.Title
}
if !result.IsDefaultBody {
// Custom-body path: render each expanded markdown body to HTML, then
// wrap the whole thing in the alert_email_notification layout (or fall
// back to plain <div> wrapping when the template store is absent).
for i, body := range result.Body {
if body == "" {
continue
}
rendered, err := markdownrenderer.RenderHTML(body)
if err != nil {
return "", "", err
}
result.Body[i] = rendered
}
appendRelatedLinkButtons(alerts, result.Body)
html, err := n.renderLayout(ctx, result)
if err != nil {
n.logger.WarnContext(ctx, "custom email template rendering failed, falling back to plain <div> wrap", errors.Attr(err))
return subject, wrapBodiesAsDivs(result.Body), nil
}
return subject, html, nil
}
// Default-body path: use the HTML config template if available.
if len(n.conf.HTML) > 0 {
body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data)
if err != nil {
return "", "", errors.WrapInternalf(err, errors.CodeInternal, "execute html template")
}
return subject, body, nil
}
return subject, "", nil
}
// renderLayout wraps result in the alert_email_notification HTML layout.
// Returns an error when the store has no such template (e.g. in tests using
// an empty store) so prepareContent can fall back to plain <div> wrapping.
func (n *Email) renderLayout(ctx context.Context, result *alertmanagertypes.ExpandResult) (string, error) {
if n.templateStore == nil {
return "", errors.NewInternalf(errors.CodeInternal, "no email template store configured")
}
layout, err := n.templateStore.Get(ctx, emailtypes.TemplateNameAlertEmailNotification)
if err != nil {
return "", err
}
bodies := make([]htmltemplate.HTML, 0, len(result.Body))
for _, b := range result.Body {
bodies = append(bodies, htmltemplate.HTML(b))
}
data := layoutData{Title: result.Title, Bodies: bodies}
if result.NotificationData != nil {
data.NotificationTemplateData = *result.NotificationData
}
var buf bytes.Buffer
if err := layout.Execute(&buf, data); err != nil {
return "", errors.WrapInternalf(err, errors.CodeInternal, "failed to render email layout")
}
return buf.String(), nil
}
// appendRelatedLinkButtons appends "View Related Logs/Traces" buttons to each
// per-alert body when the rule manager attached the corresponding annotation.
// bodies is positionally aligned with alerts (see alertmanagertemplate.Prepare);
// empty bodies are skipped so we never attach a button to an alert that produced
// no visible content.
func appendRelatedLinkButtons(alerts []*types.Alert, bodies []string) {
for i := range bodies {
if i >= len(alerts) || bodies[i] == "" {
continue
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
bodies[i] += htmlButton("View Related Logs", string(link))
}
if link := alerts[i].Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
bodies[i] += htmlButton("View Related Traces", string(link))
}
}
}
func wrapBodiesAsDivs(bodies []string) string {
var b strings.Builder
for _, part := range bodies {
if part == "" {
continue
}
b.WriteString("<div>")
b.WriteString(part)
b.WriteString("</div>")
}
return b.String()
}
func htmlButton(text, url string) string {
return fmt.Sprintf(`
<a href="%s" target="_blank" style="text-decoration: none;">
<button style="
padding: 6px 16px;
/* Default System Font */
font-family: sans-serif;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
/* Light Theme & Dynamic Background (Solid) */
color: #111827;
background-color: #f9fafb;
/* Static Outline */
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
">
%s
</button>
</a>`, url, text)
}
type loginAuth struct {
username, password string
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
@@ -18,12 +17,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/emailing/templatestore/filetemplatestore"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/emersion/go-smtp"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -48,18 +42,6 @@ const (
emailFrom = "alertmanager@example.com"
)
// testTemplater returns a Templater bound to tmpl with a discard logger.
func testTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// testEmptyStore returns an email template store with no templates. When the
// email notifier tries to render the alert_email_notification layout against
// this, the Get call fails and prepareContent falls back to plain <div> wrap.
func testEmptyStore() emailtypes.TemplateStore {
return filetemplatestore.NewEmptyStore()
}
// email represents an email returned by the MailDev REST API.
// See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
type email struct {
@@ -180,7 +162,7 @@ func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.Email
return nil, false, err
}
email := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl), testEmptyStore())
email := New(cfg, tmpl, promslog.NewNopLogger())
retry, err := email.Notify(ctx, firingAlert)
if err != nil {
@@ -724,7 +706,7 @@ func TestEmailRejected(t *testing.T) {
tmpl, firingAlert, err := prepare(cfg)
require.NoError(t, err)
e := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl), testEmptyStore())
e := New(cfg, tmpl, promslog.NewNopLogger())
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
@@ -1048,103 +1030,6 @@ func TestEmailImplicitTLS(t *testing.T) {
}
}
func TestPrepareContent(t *testing.T) {
t.Run("custom template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
bodyTpl := "line $labels.instance"
a1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("one"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
a2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
model.LabelName("instance"): model.LabelValue("two"),
},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationBodyTemplate): model.LabelValue(bodyTpl),
},
},
}
alerts := []*types.Alert{a1, a2}
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "subj"}}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl), testEmptyStore())
ctx := context.Background()
data := notify.GetTemplateData(ctx, tmpl, alerts, n.logger)
_, htmlBody, err := n.prepareContent(ctx, alerts, data)
require.NoError(t, err)
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
})
t.Run("default template with HTML and custom title template", func(t *testing.T) {
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
Annotations: model.LabelSet{
model.LabelName(ruletypes.AnnotationTitleTemplate): model.LabelValue("fixed from $alert.status"),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
cfg := &config.EmailConfig{
Headers: map[string]string{},
HTML: "Status: {{ .Status }}",
}
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl), testEmptyStore())
ctx := context.Background()
data := notify.GetTemplateData(ctx, tmpl, alerts, n.logger)
subject, htmlBody, err := n.prepareContent(ctx, alerts, data)
require.NoError(t, err)
require.Equal(t, "Status: firing", htmlBody)
// custom title supplied → prepareContent returns a subject override.
require.Equal(t, "fixed from firing", subject)
})
t.Run("default template without HTML", func(t *testing.T) {
cfg := &config.EmailConfig{Headers: map[string]string{"Subject": "the email subject"}}
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
tmpl.ExternalURL, _ = url.Parse("http://am")
n := New(cfg, tmpl, promslog.NewNopLogger(), testTemplater(tmpl), testEmptyStore())
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{firingAlert}
ctx := context.Background()
data := notify.GetTemplateData(ctx, tmpl, alerts, n.logger)
subject, htmlBody, err := n.prepareContent(ctx, alerts, data)
require.NoError(t, err)
require.Equal(t, "", htmlBody)
// No custom title annotation → empty subject signals "use the configured
// Subject header", which Notify then runs through the normal header
// template pipeline.
require.Equal(t, "", subject)
})
}
func ptrTo(b bool) *bool {
return &b
}

View File

@@ -15,9 +15,7 @@ import (
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -46,7 +44,6 @@ type Notifier struct {
retrier *notify.Retrier
webhookURL *config.SecretURL
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
templater alertmanagertypes.Templater
}
// https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema
@@ -97,7 +94,7 @@ type teamsMessage struct {
}
// New returns a new notifier that uses the Microsoft Teams Power Platform connector.
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -112,7 +109,6 @@ func New(c *config.MSTeamsV2Config, t *template.Template, titleLink string, l *s
retrier: &notify.Retrier{},
webhookURL: c.WebhookURL,
postJSONFunc: notify.PostJSON,
templater: templater,
}
return n, nil
@@ -132,11 +128,25 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return false, err
}
title := tmpl(n.conf.Title)
if err != nil {
return false, err
}
titleLink := tmpl(n.titleLink)
if err != nil {
return false, err
}
alerts := types.Alerts(as...)
color := colorGrey
switch alerts.Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
var url string
if n.conf.WebhookURL != nil {
url = n.conf.WebhookURL.String()
@@ -148,12 +158,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
url = strings.TrimSpace(string(content))
}
bodyBlocks, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema
t := teamsMessage{
Type: "message",
@@ -165,7 +169,17 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
Schema: "http://adaptivecards.io/schemas/adaptive-card.json",
Type: "AdaptiveCard",
Version: "1.2",
Body: bodyBlocks,
Body: []Body{
{
Type: "TextBlock",
Text: title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
},
},
Actions: []Action{
{
Type: "Action.OpenUrl",
@@ -181,6 +195,20 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
},
}
// add labels and annotations to the body of all alerts
for _, alert := range as {
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
t.Attachments[0].Content.Body = append(t.Attachments[0].Content.Body, n.createLabelsAndAnnotationsBody(alert)...)
}
var payload bytes.Buffer
if err = json.NewEncoder(&payload).Encode(t); err != nil {
return false, err
@@ -200,77 +228,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return shouldRetry, err
}
// prepareContent builds the Adaptive Card body blocks for the notification.
// The first block is always the title; the remainder depends on whether the
// alerts carried a custom body template.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) ([]Body, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
// Default body is not a template — it's built per-alert below from
// labels/annotations as Adaptive Card FactSets, so leave it empty.
DefaultBodyTemplate: "",
}, alerts)
if err != nil {
return nil, err
}
color := colorGrey
switch types.Alerts(alerts...).Status() {
case model.AlertFiring:
color = colorRed
case model.AlertResolved:
color = colorGreen
}
blocks := []Body{{
Type: "TextBlock",
Text: result.Title,
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Style: "heading",
Color: color,
}}
if result.IsDefaultBody {
for _, alert := range alerts {
blocks = append(blocks, Body{
Type: "TextBlock",
Text: "Alerts",
Weight: "Bolder",
Size: "Medium",
Wrap: true,
Color: color,
})
blocks = append(blocks, n.createLabelsAndAnnotationsBody(alert)...)
}
return blocks, nil
}
// Custom body path: result.Body is positionally aligned with alerts;
// entries for alerts whose template rendered empty are kept as "" so we
// can skip them here without shifting the per-alert color index.
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
perAlertColor := colorRed
if alerts[i].Resolved() {
perAlertColor = colorGreen
}
blocks = append(blocks, Body{
Type: "TextBlock",
Text: body,
Wrap: true,
Color: perAlertColor,
})
}
return blocks, nil
}
func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
bodies := []Body{}
bodies = append(bodies, Body{
@@ -301,11 +258,7 @@ func (*Notifier) createLabelsAndAnnotationsBody(alert *types.Alert) []Body {
annotationsFacts := []Fact{}
for k, v := range alert.Annotations {
// Skip private (`_`-prefixed) annotations — templating inputs,
// threshold metadata, related-link URLs — and "summary", which default
// channels surface as the attachment pretext rather than a fact row.
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) ||
alertmanagertypes.IsPrivateAnnotation(string(k)) {
if slices.Contains([]string{"summary", "related_logs", "related_traces"}, string(k)) {
continue
}
annotationsFacts = append(annotationsFacts, Fact{Title: string(k), Value: string(v)})

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -16,9 +15,6 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -27,28 +23,21 @@ import (
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
// This is a test URL that has been modified to not be valid.
var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx")
func TestMSTeamsV2Retry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -75,16 +64,14 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -166,8 +153,7 @@ func TestMSTeamsV2Templating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, tc.titleLink, promslog.NewNopLogger(), newTestTemplater(tmpl))
pd, err := New(tc.cfg, test.CreateTmpl(t), tc.titleLink, promslog.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
@@ -200,124 +186,20 @@ func TestMSTeamsV2RedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
}
func TestPrepareContent(t *testing.T) {
t.Run("default template - firing alerts", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
Title: "Alertname: {{ .CommonLabels.alertname }}",
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test"},
// Custom body template
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: "Firing alert: $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// First block should be the title with color (firing = red)
require.Equal(t, "Bolder", blocks[0].Weight)
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Alertname: test", blocks[0].Text)
// verify body text
require.Equal(t, "Firing alert: test", blocks[1].Text)
})
t.Run("custom template - per-alert color", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test1"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "test2"},
Annotations: model.LabelSet{
"summary": "test",
ruletypes.AnnotationTitleTemplate: "Custom Title",
ruletypes.AnnotationBodyTemplate: "custom body $alertname",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(-time.Minute),
},
},
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// total 3 blocks: title and 2 body blocks
require.True(t, len(blocks) == 3)
// First block: title color is overall color of the alerts
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
require.Equal(t, "Custom Title", blocks[0].Text)
// Body blocks should have per-alert color
require.Equal(t, colorRed, blocks[1].Color) // firing
require.Equal(t, colorGreen, blocks[2].Color) // resolved
})
}
func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
@@ -327,16 +209,14 @@ func TestMSTeamsV2ReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.MSTeamsV2Config{
WebhookURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
`{{ template "msteamsv2.default.titleLink" . }}`,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)

View File

@@ -15,10 +15,7 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -37,27 +34,25 @@ const maxMessageLenRunes = 130
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
conf *config.OpsGenieConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
}
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
}, nil
}
@@ -128,55 +123,6 @@ func safeSplit(s, sep string) []string {
return b
}
// prepareContent expands alert templates and returns the OpsGenie-ready title
// (truncated to the 130-rune limit) and HTML description. Custom bodies are
// rendered to HTML and stitched together with <hr> dividers; default bodies
// are joined with newlines (OpsGenie's legacy plain-text description).
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (string, string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Message,
DefaultBodyTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", "", err
}
var description string
if result.IsDefaultBody {
description = strings.Join(result.Body, "\n")
} else {
var b strings.Builder
first := true
for _, part := range result.Body {
if part == "" {
continue
}
rendered, renderErr := markdownrenderer.RenderHTML(part)
if renderErr != nil {
return "", "", renderErr
}
if !first {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
first = false
}
description = b.String()
}
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
return title, description, nil
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -222,10 +168,9 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
default:
message, description, err := n.prepareContent(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return nil, false, err
message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated message", slog.Any("alert", key), slog.Int("max_runes", maxMessageLenRunes))
}
createEndpointURL := n.conf.APIURL.Copy()
@@ -264,7 +209,7 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
msg := &opsGenieCreateMessage{
Alias: alias,
Message: message,
Description: description,
Description: tmpl(n.conf.Description),
Details: details,
Source: tmpl(n.conf.Source),
Responders: responders,

View File

@@ -8,16 +8,12 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -26,23 +22,16 @@ import (
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestOpsGenieRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -58,16 +47,14 @@ func TestOpsGenieRedactedURL(t *testing.T) {
defer fn()
key := "key"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -85,16 +72,14 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
_, err = f.WriteString(key)
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.OpsGenieConfig{
APIURL: &config.URL{URL: u},
APIKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -217,7 +202,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
notifier, err := New(tc.cfg, tmpl, logger)
require.NoError(t, err)
ctx := context.Background()
@@ -293,7 +278,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -336,7 +321,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger())
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -344,99 +329,6 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
}
func TestPrepareContent(t *testing.T) {
t.Run("default template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"Message": "Firing alert: test",
"Description": "Check runbook for more details",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert}
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
require.NoError(t, prepErr)
require.Equal(t, "Firing alert: test", title)
require.Equal(t, "Check runbook for more details", desc)
})
t.Run("custom template", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
logger := promslog.NewNopLogger()
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
},
tmpl: tmpl,
logger: logger,
templater: newTestTemplater(tmpl),
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alert1 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "potter-the-harry",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alert2 := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"service": "payment",
"namespace": "smart-the-rat",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "High request throughput for $service",
ruletypes.AnnotationBodyTemplate: "Alert firing in NS: $labels.namespace",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
}
alerts := []*types.Alert{alert1, alert2}
title, desc, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "High request throughput for payment", title)
// Each alert body wrapped in <div>, separated by <hr>
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
})
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)

View File

@@ -15,9 +15,7 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/alecthomas/units"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
@@ -42,22 +40,21 @@ const (
// Notifier implements a Notifier for PagerDuty notifications.
type Notifier struct {
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
conf *config.PagerdutyConfig
tmpl *template.Template
logger *slog.Logger
apiV1 string // for tests.
client *http.Client
retrier *notify.Retrier
}
// New returns a new PagerDuty notifier.
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client, templater: templater}
n := &Notifier{conf: c, tmpl: t, logger: l, client: client}
if c.ServiceKey != "" || c.ServiceKeyFile != "" {
n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
// Retrying can solve the issue on 403 (rate limiting) and 5xx response codes.
@@ -146,12 +143,11 @@ func (n *Notifier) notifyV1(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
description, truncated := notify.TruncateInRunes(title, maxV1DescriptionLenRunes)
description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated description", slog.Any("key", key), slog.Int("max_runes", maxV1DescriptionLenRunes))
}
@@ -207,7 +203,6 @@ func (n *Notifier) notifyV2(
key notify.Key,
data *template.Data,
details map[string]any,
title string,
) (bool, error) {
var tmplErr error
tmpl := notify.TmplText(n.tmpl, data, &tmplErr)
@@ -216,7 +211,7 @@ func (n *Notifier) notifyV2(
n.conf.Severity = "error"
}
summary, truncated := notify.TruncateInRunes(title, maxV2SummaryLenRunes)
summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated summary", slog.Any("key", key), slog.Int("max_runes", maxV2SummaryLenRunes))
}
@@ -299,55 +294,6 @@ func (n *Notifier) notifyV2(
return retry, err
}
// prepareTitle expands the notification title. PagerDuty has no body surface
// we care about — the description/summary field is what users see as the
// incident headline, so we feed the configured Description as the default
// title template and ignore any custom body_template entirely.
func (n *Notifier) prepareTitle(ctx context.Context, alerts []*types.Alert) (string, error) {
customTitle, _ := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
DefaultTitleTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", err
}
return result.Title, nil
}
// stripPrivateAnnotations returns a copy of alerts with every private (`_`-
// prefixed) annotation removed. These keys — templating inputs, threshold
// metadata, related-link URLs — are internal inputs to the notifier, not
// content the external receiver should see. The original alert
// objects are never mutated: they are shared with other receivers in the
// fanout.
func stripPrivateAnnotations(alerts []*types.Alert) []*types.Alert {
out := make([]*types.Alert, len(alerts))
for i, alert := range alerts {
hasPrivate := false
for k := range alert.Annotations {
if alertmanagertypes.IsPrivateAnnotation(string(k)) {
hasPrivate = true
break
}
}
if !hasPrivate {
out[i] = alert
continue
}
cloned := *alert
cloned.Annotations = make(model.LabelSet, len(alert.Annotations))
for k, v := range alert.Annotations {
if alertmanagertypes.IsPrivateAnnotation(string(k)) {
continue
}
cloned.Annotations[k] = v
}
out[i] = &cloned
}
return out
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -356,16 +302,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
}
logger := n.logger.With(slog.Any("group_key", key))
title, err := n.prepareTitle(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
}
// strip private annotations so they're not sent to the external receiver,
// pagerduty default details template contains labels and annotations.
as = stripPrivateAnnotations(as)
var (
alerts = types.Alerts(as...)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
@@ -393,7 +329,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
if n.apiV1 != "" {
nf = n.notifyV1
}
retry, err := nf(ctx, eventType, key, data, details, title)
retry, err := nf(ctx, eventType, key, data, details)
if err != nil {
if ctx.Err() != nil {
err = errors.WrapInternalf(err, errors.CodeInternal, "failed to notify PagerDuty: %v", context.Cause(ctx))

View File

@@ -9,7 +9,6 @@ import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -18,10 +17,7 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -34,20 +30,14 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestPagerDutyRetryV1(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -59,15 +49,13 @@ func TestPagerDutyRetryV1(t *testing.T) {
}
func TestPagerDutyRetryV2(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -83,15 +71,13 @@ func TestPagerDutyRedactedURLV1(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -104,16 +90,14 @@ func TestPagerDutyRedactedURLV2(t *testing.T) {
defer fn()
key := "01234567890123456789012345678901"
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKey: config.Secret(key),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -130,15 +114,13 @@ func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
ServiceKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.apiV1 = u.String()
@@ -156,16 +138,14 @@ func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
URL: &config.URL{URL: u},
RoutingKeyFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -322,8 +302,7 @@ func TestPagerDutyTemplating(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.URL = &config.URL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
tmpl := test.CreateTmpl(t)
pd, err := New(tc.cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
if pd.apiV1 != "" {
pd.apiV1 = u.String()
@@ -413,15 +392,13 @@ func TestEventSizeEnforcement(t *testing.T) {
Details: bigDetailsV1,
}
tmpl := test.CreateTmpl(t)
notifierV1, err := New(
&config.PagerdutyConfig{
ServiceKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -443,9 +420,8 @@ func TestEventSizeEnforcement(t *testing.T) {
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -560,8 +536,7 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
Links: links,
}
pdTmpl := test.CreateTmpl(t)
pagerDuty, err := New(&pagerDutyConfig, pdTmpl, promslog.NewNopLogger(), newTestTemplater(pdTmpl))
pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
@@ -628,8 +603,7 @@ func TestPagerDutyTimeout(t *testing.T) {
Timeout: tt.timeout,
}
tmpl := test.CreateTmpl(t)
pd, err := New(&cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
@@ -907,132 +881,3 @@ func TestRenderDetails(t *testing.T) {
})
}
}
func TestPrepareContent(t *testing.T) {
prepareContext := func() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
ctx = notify.WithReceiverName(ctx, "test-receiver")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "HighCPU for Payment service"})
return ctx
}
t.Run("default template uses go text template config for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
Description: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "HighCPU for Payment service"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
})
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.PagerdutyConfig{
RoutingKey: config.Secret("01234567890123456789012345678901"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
ctx := prepareContext()
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "HighCPU",
"service": "api-server",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "$rule.name on $service is in $alert.status state",
},
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now(),
},
},
}
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
require.Equal(t, "HighCPU on api-server is in resolved state", title)
})
}
func TestStripPrivateAnnotations(t *testing.T) {
t.Run("template annotations are removed from the copy, originals untouched", func(t *testing.T) {
// The original alert objects are shared with other receivers in the
// fanout and must not be mutated. The rendered values should not be
// sent to the webhook receiver at all.
origTitle := model.LabelValue("Alert: $labels.alertname")
origBody := model.LabelValue("Severity is $labels.severity")
in := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{"alertname": "TestAlert", "severity": "critical"},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: origTitle,
ruletypes.AnnotationBodyTemplate: origBody,
"summary": "keep this",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
out := stripPrivateAnnotations(in)
require.Len(t, out, 1)
_, hasTitle := out[0].Annotations[ruletypes.AnnotationTitleTemplate]
_, hasBody := out[0].Annotations[ruletypes.AnnotationBodyTemplate]
require.False(t, hasTitle, "title_template should be stripped from outgoing payload")
require.False(t, hasBody, "body_template should be stripped from outgoing payload")
require.Equal(t, model.LabelValue("keep this"), out[0].Annotations["summary"])
// Original alert annotations remain so downstream receivers still see
// the raw templates.
require.Equal(t, origTitle, in[0].Annotations[ruletypes.AnnotationTitleTemplate])
require.Equal(t, origBody, in[0].Annotations[ruletypes.AnnotationBodyTemplate])
})
t.Run("alerts without template annotations are returned unchanged", func(t *testing.T) {
original := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{"alertname": "NoTemplateAlert"},
Annotations: model.LabelSet{
"summary": "keep this",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}}
out := stripPrivateAnnotations([]*types.Alert{original})
require.Len(t, out, 1)
// Pass-through optimization: when there's nothing to strip, the
// original pointer is reused rather than deep-copying.
require.Same(t, original, out[0])
})
}

View File

@@ -26,15 +26,12 @@ var customNotifierIntegrations = []string{
msteamsv2.Integration,
}
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, deps alertmanagertypes.NotificationDeps) ([]notify.Integration, error) {
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error) {
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
if err != nil {
return nil, err
}
templater := deps.Templater
emailStore := deps.EmailTemplateStore
var (
errs types.MultiError
integrations []notify.Integration
@@ -56,25 +53,23 @@ func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Templ
}
for i, c := range nc.WebhookConfigs {
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, templater) })
add(webhook.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l) })
}
for i, c := range nc.EmailConfigs {
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return email.New(c, tmpl, l, templater, emailStore), nil
})
add(email.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil })
}
for i, c := range nc.PagerdutyConfigs {
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l) })
}
for i, c := range nc.MSTeamsV2Configs {
add(msteamsv2.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l, templater)
return msteamsv2.New(c, tmpl, `{{ template "msteamsv2.default.titleLink" . }}`, l)
})
}

View File

@@ -14,11 +14,7 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/alertmanager/config"
@@ -29,8 +25,6 @@ import (
const (
Integration = "slack"
colorRed = "#FF0000"
colorGreen = "#00FF00"
)
// https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters.
@@ -38,18 +32,17 @@ const maxTitleLenRunes = 1024
// Notifier implements a Notifier for Slack notifications.
type Notifier struct {
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
conf *config.SlackConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}
// New returns a new Slack notification handler.
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -61,7 +54,6 @@ func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, templater
logger: l,
client: client,
retrier: &notify.Retrier{},
templater: templater,
postJSONFunc: notify.PostJSON,
}, nil
}
@@ -89,10 +81,9 @@ type attachment struct {
Actions []config.SlackAction `json:"actions,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Footer string `json:"footer,omitempty"`
Footer string `json:"footer"`
Color string `json:"color,omitempty"`
MrkdwnIn []string `json:"mrkdwn_in,omitempty"`
Blocks []any `json:"blocks,omitempty"`
}
// Notify implements the Notifier interface.
@@ -109,15 +100,79 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
data = notify.GetTemplateData(ctx, n.tmpl, as, logger)
tmplText = notify.TmplText(n.tmpl, data, &err)
)
var markdownIn []string
attachments, err := n.prepareContent(ctx, as, tmplText)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return false, err
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
}
if len(attachments) > 0 {
n.addFieldsAndActions(&attachments[0], tmplText)
title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes)
if truncated {
logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
att := &attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
}
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
// Check if short was defined for the field otherwise fallback to the global setting
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
// Rebuild the field by executing any templates and setting the new value for short
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
}
req := &request{
@@ -127,7 +182,7 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
IconURL: tmplText(n.conf.IconURL),
LinkNames: n.conf.LinkNames,
Text: tmplText(n.conf.MessageText),
Attachments: attachments,
Attachments: []attachment{*att},
}
if err != nil {
return false, err
@@ -183,153 +238,6 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
return retry, nil
}
// prepareContent expands alert templates and returns the Slack attachment(s)
// ready to send. When alerts carry a custom body template, one title-only
// attachment plus one body attachment per alert is returned so that each alert
// can get its own firing/resolved color and per-alert action buttons.
func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert, tmplText func(string) string) ([]attachment, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Title,
// The default path renders the attachment body using the configured
// Text template (and Slack's own mrkdwn flavour) below, so the default
// body template here is left empty.
DefaultBodyTemplate: "",
}, alerts)
if err != nil {
return nil, err
}
title, truncated := notify.TruncateInRunes(result.Title, maxTitleLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated title", slog.Int("max_runes", maxTitleLenRunes))
}
if result.IsDefaultBody {
var markdownIn []string
if len(n.conf.MrkdwnIn) == 0 {
markdownIn = []string{"fallback", "pretext", "text"}
} else {
markdownIn = n.conf.MrkdwnIn
}
return []attachment{
{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
Pretext: tmplText(n.conf.Pretext),
Text: tmplText(n.conf.Text),
Fallback: tmplText(n.conf.Fallback),
CallbackID: tmplText(n.conf.CallbackID),
ImageURL: tmplText(n.conf.ImageURL),
ThumbURL: tmplText(n.conf.ThumbURL),
Footer: tmplText(n.conf.Footer),
Color: tmplText(n.conf.Color),
MrkdwnIn: markdownIn,
},
}, nil
}
// Custom template path: one title attachment + one attachment per
// non-empty alert body. result.Body is positionally aligned with alerts,
// so we index alerts[i] directly and skip empty entries.
attachments := make([]attachment, 0, 1+len(result.Body))
attachments = append(attachments, attachment{
Title: title,
TitleLink: tmplText(n.conf.TitleLink),
})
for i, body := range result.Body {
if body == "" || i >= len(alerts) {
continue
}
// Custom bodies are authored in markdown; render each non-empty body to
// Slack's mrkdwn flavour. Default bodies skip this because the Text
// template is already channel-ready.
rendered, renderErr := markdownrenderer.RenderSlackMrkdwn(body)
if renderErr != nil {
return nil, renderErr
}
color := colorRed
if alerts[i].Resolved() {
color = colorGreen
}
attachments = append(attachments, attachment{
Text: rendered,
Color: color,
MrkdwnIn: []string{"text"},
Actions: buildRelatedLinkActions(alerts[i]),
})
}
return attachments, nil
}
// buildRelatedLinkActions returns the "View Related Logs/Traces" action
// buttons for an alert, or nil when no related-link annotations are present.
func buildRelatedLinkActions(alert *types.Alert) []config.SlackAction {
var actions []config.SlackAction
if link := alert.Annotations[ruletypes.AnnotationRelatedLogs]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Logs", URL: string(link)})
}
if link := alert.Annotations[ruletypes.AnnotationRelatedTraces]; link != "" {
actions = append(actions, config.SlackAction{Type: "button", Text: "View Related Traces", URL: string(link)})
}
return actions
}
// addFieldsAndActions populates fields and actions on the attachment from the Slack config.
func (n *Notifier) addFieldsAndActions(att *attachment, tmplText func(string) string) {
numFields := len(n.conf.Fields)
if numFields > 0 {
fields := make([]config.SlackField, numFields)
for index, field := range n.conf.Fields {
var short bool
if field.Short != nil {
short = *field.Short
} else {
short = n.conf.ShortFields
}
fields[index] = config.SlackField{
Title: tmplText(field.Title),
Value: tmplText(field.Value),
Short: &short,
}
}
att.Fields = fields
}
numActions := len(n.conf.Actions)
if numActions > 0 {
actions := make([]config.SlackAction, numActions)
for index, action := range n.conf.Actions {
slackAction := config.SlackAction{
Type: tmplText(action.Type),
Text: tmplText(action.Text),
URL: tmplText(action.URL),
Style: tmplText(action.Style),
Name: tmplText(action.Name),
Value: tmplText(action.Value),
}
if action.ConfirmField != nil {
slackAction.ConfirmField = &config.SlackConfirmationField{
Title: tmplText(action.ConfirmField.Title),
Text: tmplText(action.ConfirmField.Text),
OkText: tmplText(action.ConfirmField.OkText),
DismissText: tmplText(action.ConfirmField.DismissText),
}
}
actions[index] = slackAction
}
att.Actions = actions
}
}
// checkResponseError parses out the error message from Slack API response.
func checkResponseError(resp *http.Response) (bool, error) {
body, err := io.ReadAll(resp.Body)

View File

@@ -17,9 +17,6 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -32,19 +29,13 @@ import (
"github.com/prometheus/alertmanager/types"
)
func newTestTemplater(tmpl *template.Template) alertmanagertypes.Templater {
return alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))
}
func TestSlackRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -58,15 +49,13 @@ func TestSlackRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -82,15 +71,13 @@ func TestGettingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String())
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -106,15 +93,13 @@ func TestTrimmingSlackURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
APIURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -199,7 +184,6 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiurl, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -207,9 +191,8 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
APIURL: &config.SecretURL{URL: apiurl},
Channel: "channelname",
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
@@ -259,7 +242,6 @@ func TestSlackTimeout(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
u, _ := url.Parse("https://slack.com/post.Message")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.SlackConfig{
NotifierConfig: config.NotifierConfig{},
@@ -268,9 +250,8 @@ func TestSlackTimeout(t *testing.T) {
Channel: "channelname",
Timeout: tt.timeout,
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
newTestTemplater(tmpl),
)
require.NoError(t, err)
notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) {
@@ -301,225 +282,6 @@ func TestSlackTimeout(t *testing.T) {
}
}
// setupTestContext creates a context with group key, receiver name, and group labels
// required by the notification processor.
func setupTestContext() context.Context {
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group")
ctx = notify.WithReceiverName(ctx, "slack")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{
"alertname": "TestAlert",
"severity": "critical",
})
return ctx
}
func TestPrepareContent(t *testing.T) {
t.Run("default template uses go text template config for title and body", func(t *testing.T) {
// When alerts have no custom annotation templates (title_template / body_template),
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }} ({{ .Status | toUpper }})`,
Text: `{{ range .Alerts }}Alert: {{ .Labels.alertname }} - severity {{ .Labels.severity }}{{ end }}`,
Color: `{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}`,
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
// Build tmplText the same way Notify does
var err error
data := notify.GetTemplateData(ctx, tmpl, alerts, slog.New(slog.DiscardHandler))
tmplText := notify.TmplText(tmpl, data, &err)
atts, attErr := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, attErr)
require.NoError(t, err)
require.Len(t, atts, 1)
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
// Color is templated — firing alert should be "danger"
require.Equal(t, "danger", atts[0].Color)
// No BlockKit blocks for default template
require.Nil(t, atts[0].Blocks)
// Default markdownIn when config has none
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
})
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
// When alerts carry custom $variable annotation templates (title_template / body_template)
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
notifier := &Notifier{
conf: &config.SlackConfig{
Title: "default title fallback",
Text: "default text fallback",
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
bodyTemplate := `## $rule.name
**Service:** *$labels.service*
**Instance:** *$labels.instance*
**Region:** *$labels.region*
**Method:** *$labels.http_method*
---
| Metric | Value |
|--------|-------|
| **Current** | *$value* |
| **Threshold** | *$threshold.value* |
**Status:** $alert.status | **Severity:** $labels.severity`
titleTemplate := "[$alert.status] $rule.name — $labels.service"
ctx := setupTestContext()
firingAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: model.LabelValue(titleTemplate),
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
"value": "100",
"threshold.value": "200",
},
},
}
resolvedAlert := &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "HighCPU", ruletypes.LabelSeverityName: "critical", "service": "api-server", "instance": "i-0abc123", "region": "us-east-1", "http_method": "GET"},
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),
"value": "50",
"threshold.value": "200",
},
},
}
atts, err := notifier.prepareContent(ctx, []*types.Alert{firingAlert, resolvedAlert}, tmplText)
require.NoError(t, err)
// 1 title attachment + 2 body attachments (one per alert)
require.Len(t, atts, 3)
// First attachment: title-only, no color, no blocks
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
require.Empty(t, atts[0].Color)
require.Nil(t, atts[0].Blocks)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
expectedFiringBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 100 \nThreshold | 200 \n```\n\n" +
"*Status:* firing | *Severity:* critical\n\n"
expectedResolvedBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
"---\n\n" +
"```\nMetric | Value\n----------|------\nCurrent | 50 \nThreshold | 200 \n```\n\n" +
"*Status:* resolved | *Severity:* critical\n\n"
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
require.Nil(t, atts[1].Blocks)
require.Equal(t, "#FF0000", atts[1].Color)
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
require.Equal(t, expectedFiringBody, atts[1].Text)
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
require.Nil(t, atts[2].Blocks)
require.Equal(t, "#00FF00", atts[2].Color)
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
require.Equal(t, expectedResolvedBody, atts[2].Text)
})
t.Run("default template with fields and actions", func(t *testing.T) {
// Verifies that addFieldsAndActions (called from Notify after prepareContent)
// correctly populates fields and actions on the attachment from config.
tmpl := test.CreateTmpl(t)
templater := newTestTemplater(tmpl)
short := true
notifier := &Notifier{
conf: &config.SlackConfig{
Title: `{{ .CommonLabels.alertname }}`,
Text: "alert text",
Color: "warning",
Fields: []*config.SlackField{
{Title: "Severity", Value: "critical", Short: &short},
{Title: "Service", Value: "api-server", Short: &short},
},
Actions: []*config.SlackAction{
{Type: "button", Text: "View Alert", URL: "https://alertmanager.signoz.com"},
},
TitleLink: "https://alertmanager.signoz.com",
},
tmpl: tmpl,
logger: slog.New(slog.DiscardHandler),
templater: templater,
}
tmplText := func(s string) string { return s }
ctx := setupTestContext()
alerts := []*types.Alert{
{Alert: model.Alert{
Labels: model.LabelSet{ruletypes.LabelAlertName: "TestAlert"},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
}},
}
atts, err := notifier.prepareContent(ctx, alerts, tmplText)
require.NoError(t, err)
require.Len(t, atts, 1)
// prepareContent does not populate fields/actions — that's done by
// addFieldsAndActions which is called from Notify.
require.Nil(t, atts[0].Fields)
require.Nil(t, atts[0].Actions)
// Simulate what Notify does after prepareContent
notifier.addFieldsAndActions(&atts[0], tmplText)
// Verify fields
require.Len(t, atts[0].Fields, 2)
require.Equal(t, "Severity", atts[0].Fields[0].Title)
require.Equal(t, "critical", atts[0].Fields[0].Value)
require.True(t, *atts[0].Fields[0].Short)
require.Equal(t, "Service", atts[0].Fields[1].Title)
require.Equal(t, "api-server", atts[0].Fields[1].Value)
// Verify actions
require.Len(t, atts[0].Actions, 1)
require.Equal(t, "button", atts[0].Actions[0].Type)
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
})
}
func TestSlackMessageField(t *testing.T) {
// 1. Setup a fake Slack server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -567,7 +329,7 @@ func TestSlackMessageField(t *testing.T) {
tmpl.ExternalURL = u
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
notifier, err := New(conf, tmpl, logger)
if err != nil {
t.Fatal(err)
}

View File

@@ -13,12 +13,8 @@ import (
"os"
"strings"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
@@ -27,23 +23,20 @@ import (
)
const (
Integration = "webhook"
templatedTitle = "templated_title"
templatedBody = "templated_body"
Integration = "webhook"
)
// Notifier implements a Notifier for generic webhooks.
type Notifier struct {
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertemplate.Templater
conf *config.WebhookConfig
tmpl *template.Template
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
}
// New returns a new Webhook.
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templater alertmanagertemplate.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
@@ -55,8 +48,7 @@ func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, templ
client: client,
// Webhooks are assumed to respond with 2xx response codes on a successful
// request and 5xx response codes are assumed to be recoverable.
retrier: &notify.Retrier{},
templater: templater,
retrier: &notify.Retrier{},
}, nil
}
@@ -78,48 +70,9 @@ func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, ui
return alerts, 0
}
// templateTitleBody extracts custom templates from alert annotations, expands them and
// replaces the private annotations with the rendered title and body.
func (n *Notifier) templateTitleBody(ctx context.Context, alerts []*types.Alert) error {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
}, alerts)
if err != nil {
return err
}
for i, alert := range alerts {
if alert.Annotations == nil {
continue
}
// Update templated_title annotation with rendered title, only if key exists and result is non-blank
if _, ok := alert.Annotations[ruletypes.AnnotationTitleTemplate]; ok {
delete(alert.Annotations, ruletypes.AnnotationTitleTemplate)
if result.Title != "" {
alert.Annotations[templatedTitle] = model.LabelValue(result.Title)
}
}
// Update templated_body annotation with rendered body, only if key exists and result is non-blank
if _, ok := alert.Annotations[ruletypes.AnnotationBodyTemplate]; ok {
delete(alert.Annotations, ruletypes.AnnotationBodyTemplate)
if i < len(result.Body) && result.Body[i] != "" {
alert.Annotations[templatedBody] = model.LabelValue(result.Body[i])
}
}
}
return nil
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) {
alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts)
// expand custom templating annotations
if err := n.templateTitleBody(ctx, alerts); err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
}
data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger)
groupKey, err := notify.ExtractGroupKey(ctx)

View File

@@ -9,7 +9,6 @@ import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
@@ -21,8 +20,6 @@ import (
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
@@ -30,15 +27,13 @@ import (
)
func TestWebhookRetry(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL("http://example.com"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
if err != nil {
require.NoError(t, err)
@@ -101,16 +96,13 @@ func TestWebhookRedactedURL(t *testing.T) {
defer fn()
secret := "secret"
tmpl := test.CreateTmpl(t)
templater := alertmanagertemplate.New(tmpl, slog.Default())
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(u.String()),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
templater,
)
require.NoError(t, err)
@@ -126,15 +118,13 @@ func TestWebhookReadingURLFromFile(t *testing.T) {
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)
@@ -188,15 +178,13 @@ func TestWebhookURLTemplating(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
calledURL = "" // Reset for each test
tmpl := test.CreateTmpl(t)
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL(tc.url),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
test.CreateTmpl(t),
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)
@@ -228,103 +216,3 @@ func TestWebhookURLTemplating(t *testing.T) {
})
}
}
func TestTemplateTitleBody(t *testing.T) {
tmpl := test.CreateTmpl(t)
templater := alertmanagertemplate.New(tmpl, slog.Default())
notifier, err := New(
&config.WebhookConfig{
URL: config.SecretTemplateURL("http://example.com"),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
tmpl,
slog.Default(),
templater,
)
require.NoError(t, err)
t.Run("annotations are updated with custom title and body templates", func(t *testing.T) {
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "TestAlert",
"severity": "critical",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Alert: $labels.alertname",
ruletypes.AnnotationBodyTemplate: "Severity is $labels.severity",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "TestAlert",
"severity": "warning",
},
Annotations: model.LabelSet{
ruletypes.AnnotationTitleTemplate: "Alert: $labels.alertname",
ruletypes.AnnotationBodyTemplate: "Severity is $labels.severity",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
ctx := context.Background()
err := notifier.templateTitleBody(ctx, alerts)
require.NoError(t, err)
for _, a := range alerts {
_, hasTitleTmpl := a.Annotations[ruletypes.AnnotationTitleTemplate]
_, hasBodyTmpl := a.Annotations[ruletypes.AnnotationBodyTemplate]
require.False(t, hasTitleTmpl, "private title template key should be stripped")
require.False(t, hasBodyTmpl, "private body template key should be stripped")
}
require.Equal(t, model.LabelValue("Alert: TestAlert"), alerts[0].Annotations[templatedTitle])
require.Equal(t, model.LabelValue("Alert: TestAlert"), alerts[1].Annotations[templatedTitle])
require.Equal(t, model.LabelValue("Severity is critical"), alerts[0].Annotations[templatedBody])
require.Equal(t, model.LabelValue("Severity is warning"), alerts[1].Annotations[templatedBody])
})
t.Run("annotations not updated when template keys are absent", func(t *testing.T) {
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "NoTemplateAlert",
},
Annotations: model.LabelSet{
"summary": "keep this",
"description": "keep this too",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
ctx := context.Background()
err := notifier.templateTitleBody(ctx, alerts)
require.NoError(t, err)
_, hasTitleTemplate := alerts[0].Annotations[ruletypes.AnnotationTitleTemplate]
_, hasBodyTemplate := alerts[0].Annotations[ruletypes.AnnotationBodyTemplate]
require.False(t, hasTitleTemplate, "title_template should not be added when absent")
require.False(t, hasBodyTemplate, "body_template should not be added when absent")
_, hasTemplatedTitle := alerts[0].Annotations[templatedTitle]
_, hasTemplatedBody := alerts[0].Annotations[templatedBody]
require.False(t, hasTemplatedTitle, "templated_title should not be added when no custom templates")
require.False(t, hasTemplatedBody, "templated_body should not be added when no custom templates")
require.Equal(t, model.LabelValue("keep this"), alerts[0].Annotations["summary"])
require.Equal(t, model.LabelValue("keep this too"), alerts[0].Annotations["description"])
})
}

View File

@@ -28,9 +28,6 @@ type Config struct {
// Configuration for the notification log.
NFLog NFLogConfig `mapstructure:"nflog"`
// EmailTemplatesDirectory is the directory containing email layout templates (.gotmpl files).
EmailTemplatesDirectory string `mapstructure:"email_templates_directory"`
}
type AlertsConfig struct {
@@ -103,6 +100,5 @@ func NewConfig() Config {
MaintenanceInterval: 15 * time.Minute,
Retention: 120 * time.Hour,
},
EmailTemplatesDirectory: "/root/templates",
}
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/prometheus/alertmanager/types"
"golang.org/x/sync/errgroup"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/inhibit"
@@ -24,11 +23,9 @@ import (
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/emailing/templatestore/filetemplatestore"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
)
var (
@@ -69,8 +66,6 @@ type Server struct {
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.MemMarker
tmpl *template.Template
notificationDeps alertmanagertypes.NotificationDeps
emailTemplateStore emailtypes.TemplateStore
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
@@ -203,12 +198,6 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
emailTemplateStore, storeErr := filetemplatestore.NewStore(ctx, srvConfig.EmailTemplatesDirectory, emailtypes.Templates, server.logger)
if storeErr != nil {
server.logger.ErrorContext(ctx, "failed to create alert email template store, using empty store", errors.Attr(storeErr))
emailTemplateStore = filetemplatestore.NewEmptyStore()
}
server.emailTemplateStore = emailTemplateStore
return server, nil
}
@@ -245,11 +234,6 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.tmpl.ExternalURL = server.srvConfig.ExternalURL
server.notificationDeps = alertmanagertypes.NotificationDeps{
Templater: alertmanagertemplate.New(server.tmpl, server.logger),
EmailTemplateStore: server.emailTemplateStore,
}
// Build the routing tree and record which receivers are used.
routes := dispatch.NewRoute(config.Route, nil)
activeReceivers := make(map[string]struct{})
@@ -266,7 +250,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.notificationDeps)
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger)
if err != nil {
return err
}
@@ -342,7 +326,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.notificationDeps, testAlert.Labels, testAlert)
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, testAlert.Labels, testAlert)
}
func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmanagertypes.PostableAlert][]string, config *alertmanagertypes.NotificationConfig) error {
@@ -425,7 +409,6 @@ func (server *Server) TestAlert(ctx context.Context, receiversMap map[*alertmana
server.alertmanagerConfig,
server.tmpl,
server.logger,
server.notificationDeps,
group.groupLabels,
group.alerts...,
)

View File

@@ -137,9 +137,6 @@ func (at *templater) expandTitle(
}
// expandBody expands the body template for each individual alert. Returns nil if the template is empty.
// Non-nil results are positionally aligned with ntd.Alerts: sb[i] corresponds to alerts[i], and
// entries for alerts whose template expands to empty are kept as "" so callers can index per-alert
// metadata (related links, firing/resolved color) by the same index.
func (at *templater) expandBody(
bodyTemplate string,
ntd *alertmanagertypes.NotificationTemplateData,
@@ -147,7 +144,7 @@ func (at *templater) expandBody(
if bodyTemplate == "" {
return nil, nil, nil
}
sb := make([]string, len(ntd.Alerts))
var sb []string
missingVars := make(map[string]bool)
for i := range ntd.Alerts {
processRes, err := preProcessTemplateAndData(bodyTemplate, &ntd.Alerts[i])
@@ -158,10 +155,13 @@ func (at *templater) expandBody(
if err != nil {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute custom body template: %s", err.Error())
}
// add unknown variables and templated text to the result
for k := range processRes.UnknownVars {
missingVars[k] = true
}
sb[i] = strings.TrimSpace(part)
if strings.TrimSpace(part) != "" {
sb = append(sb, strings.TrimSpace(part))
}
}
return sb, missingVars, nil
}
@@ -189,20 +189,17 @@ func (at *templater) buildNotificationTemplateData(
externalURL = at.tmpl.ExternalURL.String()
}
// Raw (including private `_*`) kv first so buildRuleInfo can read the
// private rule-metadata annotations (threshold, op, match_type). The
// filtered copies are what ends up on the template-visible surfaces.
rawCommonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonAnnotations := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
commonLabels := extractCommonKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
// aggregate labels and annotations from all alerts
labels := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Labels })
annotations := aggregateKV(alerts, func(a *types.Alert) model.LabelSet { return a.Annotations })
// Strip private annotations from template-visible surfaces; the structured
// fields on AlertInfo/RuleInfo already hold anything a template needs from
// them.
commonAnnotations := alertmanagertypes.FilterPublicAnnotations(rawCommonAnnotations)
// Strip private annotations from surfaces visible to templates or
// notifications; the structured fields on AlertInfo/RuleInfo already hold
// anything a template needs from them.
commonAnnotations = alertmanagertypes.FilterPublicAnnotations(commonAnnotations)
annotations = alertmanagertypes.FilterPublicAnnotations(annotations)
// build the alert data slice
@@ -236,7 +233,7 @@ func (at *templater) buildNotificationTemplateData(
TotalFiring: firing,
TotalResolved: resolved,
},
Rule: buildRuleInfo(commonLabels, rawCommonAnnotations),
Rule: buildRuleInfo(commonLabels, commonAnnotations),
GroupLabels: gl,
CommonLabels: commonLabels,
CommonAnnotations: commonAnnotations,

View File

@@ -373,13 +373,7 @@ func (handler *handler) UpdateService(rw http.ResponseWriter, r *http.Request) {
// update or create service
if svc.CloudIntegrationService == nil {
var cloudIntegrationService *cloudintegrationtypes.CloudIntegrationService
cloudIntegrationService, err = cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, provider, req.Config)
if err != nil {
render.Error(rw, err)
return
}
cloudIntegrationService := cloudintegrationtypes.NewCloudIntegrationService(serviceID, cloudIntegrationID, req.Config)
err = handler.module.CreateService(ctx, orgID, cloudIntegrationService, provider)
} else {
err = svc.CloudIntegrationService.Update(provider, serviceID, req.Config)

View File

@@ -211,23 +211,8 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
// no need to expand custom templating annotations — they get expanded in the notifier layer
if ruletypes.IsCustomTemplatingAnnotation(name) {
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
continue
}
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
// Add values to be used in notifier layer for notification templates
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationValue, Value: valueFormatter.Format(result.V, r.Unit())})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: result.CompareOperator.Literal()})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: result.MatchType.Literal()})
if result.IsRecovering {
lb.Set(ruletypes.LabelIsRecovering, "true")
}
if result.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")

View File

@@ -337,23 +337,8 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
for name, value := range r.annotations.Map() {
// no need to expand custom templating annotations — they get expanded in the notifier layer
if ruletypes.IsCustomTemplatingAnnotation(name) {
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
continue
}
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
}
// Add values to be used in notifier layer for notification templates
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationValue, Value: value})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationThresholdValue, Value: threshold})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationCompareOp, Value: smpl.CompareOperator.Literal()})
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationMatchType, Value: smpl.MatchType.Literal()})
if smpl.IsRecovering {
lb.Set(ruletypes.LabelIsRecovering, "true")
}
if smpl.IsMissing {
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
lb.Set(ruletypes.NoDataLabel, "true")
@@ -367,13 +352,13 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
link := r.prepareLinksToTraces(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
annotations = append(annotations, ruletypes.Label{Name: "related_traces", Value: fmt.Sprintf("%s/traces-explorer?%s", r.hostFromSource(), link)})
}
case ruletypes.AlertTypeLogs:
link := r.prepareLinksToLogs(ctx, ts, smpl.Metric)
if link != "" && r.hostFromSource() != "" {
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
annotations = append(annotations, ruletypes.Label{Name: "related_logs", Value: fmt.Sprintf("%s/logs/logs-explorer?%s", r.hostFromSource(), link)})
}
}

View File

@@ -869,7 +869,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
for name, value := range item.Annotations.Map() {
if name == ruletypes.AnnotationRelatedTraces {
if name == "related_traces" {
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, value, "GET")
}
@@ -986,7 +986,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
for name, value := range item.Annotations.Map() {
if name == ruletypes.AnnotationRelatedLogs {
if name == "related_logs" {
assert.NotEmpty(t, value, "case %d", idx)
assert.Contains(t, value, "testcontainer")
}

View File

@@ -1,29 +0,0 @@
package alertmanagertypes
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
// Templater expands user-authored title and body templates against a group
// of alerts. Implemented by pkg/alertmanager/alertmanagertemplate.
type Templater interface {
Expand(ctx context.Context, req ExpandRequest, alerts []*types.Alert) (*ExpandResult, error)
}
// NotificationDeps carries the shared helpers every notifier needs to turn
// custom templates into channel-ready content. EmailTemplateStore is only
// consumed by the email notifier; other channels ignore it.
type NotificationDeps struct {
Templater Templater
EmailTemplateStore emailtypes.TemplateStore
}
// ReceiverIntegrationsFunc constructs the notify.Integration list for a
// configured receiver.
type ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger, deps NotificationDeps) ([]notify.Integration, error)

View File

@@ -4,11 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/prometheus/common/model"
"log/slog"
"time"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
@@ -19,7 +18,8 @@ import (
type (
// Receiver is the type for the receiver configuration.
Receiver = config.Receiver
Receiver = config.Receiver
ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger) ([]notify.Integration, error)
)
// Creates a new receiver from a string. The input is initialized with the default values from the upstream alertmanager.
@@ -50,7 +50,7 @@ func NewReceiver(input string) (Receiver, error) {
return receiverWithDefaults, nil
}
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, deps NotificationDeps, lSet model.LabelSet, alert ...*Alert) error {
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, lSet model.LabelSet, alert ...*Alert) error {
ctx = notify.WithGroupKey(ctx, fmt.Sprintf("%s-%s-%d", receiver.Name, lSet.Fingerprint(), time.Now().Unix()))
ctx = notify.WithGroupLabels(ctx, lSet)
ctx = notify.WithReceiverName(ctx, receiver.Name)
@@ -72,7 +72,7 @@ func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFu
return err
}
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger, deps)
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger)
if err != nil {
return err
}

View File

@@ -29,13 +29,8 @@ type AgentReport struct {
}
type AccountConfig struct {
AWS *AWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
}
type UpdatableAccountConfig struct {
AWS *UpdatableAWSAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *UpdatableAzureAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
// required till new providers are added
AWS *AWSAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type PostableAccount struct {
@@ -46,8 +41,7 @@ type PostableAccount struct {
type PostableAccountConfig struct {
// as agent version is common for all providers, we can keep it at top level of this struct
AgentVersion string
AWS *AWSPostableAccountConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzurePostableAccountConfig `json:"azure,omitempty" required:"false" nullable:"false"`
AWS *AWSPostableAccountConfig `json:"aws" required:"true" nullable:"false"`
}
type Credentials struct {
@@ -64,8 +58,7 @@ type GettableAccountWithConnectionArtifact struct {
type ConnectionArtifact struct {
// required till new providers are added
AWS *AWSConnectionArtifact `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureConnectionArtifact `json:"azure,omitempty" required:"false" nullable:"false"`
AWS *AWSConnectionArtifact `json:"aws" required:"true" nullable:"false"`
}
type GetConnectionArtifactRequest = PostableAccount
@@ -75,7 +68,7 @@ type GettableAccounts struct {
}
type UpdatableAccount struct {
Config *UpdatableAccountConfig `json:"config" required:"true" nullable:"false"`
Config *AccountConfig `json:"config" required:"true" nullable:"false"`
}
func NewAccount(orgID valuer.UUID, provider CloudProviderType, config *AccountConfig) *Account {
@@ -126,13 +119,6 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
return nil, err
}
account.Config.AWS = awsConfig
case CloudProviderTypeAzure:
azureConfig := new(AzureAccountConfig)
err := json.Unmarshal([]byte(storableAccount.Config), azureConfig)
if err != nil {
return nil, err
}
account.Config.Azure = azureConfig
}
if storableAccount.LastAgentReport != nil {
@@ -193,24 +179,6 @@ func NewAccountConfigFromPostable(provider CloudProviderType, config *PostableAc
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.AWS.Regions}}, nil
case CloudProviderTypeAzure:
if config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config can not be nil for Azure provider")
}
if config.Azure.DeploymentRegion == "" {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "deployment region is required for Azure provider")
}
if err := validateAzureRegion(config.Azure.DeploymentRegion); err != nil {
return nil, err
}
if len(config.Azure.ResourceGroups) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one resource group is required for Azure provider")
}
return &AccountConfig{Azure: &AzureAccountConfig{DeploymentRegion: config.Azure.DeploymentRegion, ResourceGroups: config.Azure.ResourceGroups}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -234,16 +202,6 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
return &AccountConfig{AWS: &AWSAccountConfig{Regions: config.Config.AWS.Regions}}, nil
case CloudProviderTypeAzure:
if config.Config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config can not be nil for Azure provider")
}
if len(config.Config.Azure.ResourceGroups) == 0 {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "at least one resource group is required for Azure provider")
}
return &AccountConfig{Azure: &AzureAccountConfig{ResourceGroups: config.Config.Azure.ResourceGroups}}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -265,14 +223,8 @@ func GetSigNozAPIURLFromDeployment(deployment *zeustypes.GettableDeployment) (st
}
func (account *Account) Update(provider CloudProviderType, config *AccountConfig) error {
// deployment region can not be updated once set for Azure
if provider == CloudProviderTypeAzure {
config.Azure.DeploymentRegion = account.Config.Azure.DeploymentRegion
}
account.Config = config
account.UpdatedAt = time.Now()
return nil
}
@@ -336,10 +288,6 @@ func (config *AccountConfig) ToJSON() ([]byte, error) {
return json.Marshal(config.AWS)
}
if config.Azure != nil {
return json.Marshal(config.Azure)
}
return nil, errors.NewInternalf(errors.CodeInternal, "no provider account config found")
}

View File

@@ -48,8 +48,7 @@ type IntegrationConfig struct {
}
type ProviderIntegrationConfig struct {
AWS *AWSIntegrationConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureIntegrationConfig `json:"azure,omitempty" required:"false" nullable:"false"`
AWS *AWSIntegrationConfig `json:"aws" required:"true" nullable:"false"`
}
// NewGettableAgentCheckIn constructs a backward-compatible response from an AgentCheckInResponse.

View File

@@ -61,8 +61,7 @@ type StorableCloudIntegrationService struct {
// Following Service config types are only internally used to store service config in DB and use JSON snake case keys for backward compatibility.
type StorableServiceConfig struct {
AWS *StorableAWSServiceConfig
Azure *StorableAzureServiceConfig
AWS *StorableAWSServiceConfig
}
type StorableAWSServiceConfig struct {
@@ -79,19 +78,6 @@ type StorableAWSMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableAzureServiceConfig struct {
Logs *StorableAzureLogsServiceConfig `json:"logs,omitempty"`
Metrics *StorableAzureMetricsServiceConfig `json:"metrics,omitempty"`
}
type StorableAzureLogsServiceConfig struct {
Enabled bool `json:"enabled"`
}
type StorableAzureMetricsServiceConfig struct {
Enabled bool `json:"enabled"`
}
// Scan scans value from DB.
func (r *StorableAgentReport) Scan(src any) error {
var data []byte
@@ -201,30 +187,6 @@ func newStorableServiceConfig(provider CloudProviderType, serviceID ServiceID, s
}
return &StorableServiceConfig{AWS: storableAWSServiceConfig}, nil
case CloudProviderTypeAzure:
storableAzureServiceConfig := new(StorableAzureServiceConfig)
if supportedSignals.Logs {
if serviceConfig.Azure.Logs == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "logs config is required for Azure service: %s", serviceID.StringValue())
}
storableAzureServiceConfig.Logs = &StorableAzureLogsServiceConfig{
Enabled: serviceConfig.Azure.Logs.Enabled,
}
}
if supportedSignals.Metrics {
if serviceConfig.Azure.Metrics == nil {
return nil, errors.NewInvalidInputf(ErrCodeCloudIntegrationInvalidConfig, "metrics config is required for Azure service: %s", serviceID.StringValue())
}
storableAzureServiceConfig.Metrics = &StorableAzureMetricsServiceConfig{
Enabled: serviceConfig.Azure.Metrics.Enabled,
}
}
return &StorableServiceConfig{Azure: storableAzureServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -239,13 +201,6 @@ func newStorableServiceConfigFromJSON(provider CloudProviderType, jsonStr string
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse AWS service config JSON")
}
return &StorableServiceConfig{AWS: awsConfig}, nil
case CloudProviderTypeAzure:
azureConfig := new(StorableAzureServiceConfig)
err := json.Unmarshal([]byte(jsonStr), azureConfig)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't parse Azure service config JSON")
}
return &StorableServiceConfig{Azure: azureConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -259,13 +214,6 @@ func (config *StorableServiceConfig) toJSON(provider CloudProviderType) ([]byte,
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize AWS service config to JSON")
}
return jsonBytes, nil
case CloudProviderTypeAzure:
jsonBytes, err := json.Marshal(config.Azure)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize Azure service config to JSON")
}
return jsonBytes, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())

View File

@@ -23,8 +23,6 @@ type AWSAccountConfig struct {
Regions []string `json:"regions" required:"true" nullable:"false"`
}
type UpdatableAWSAccountConfig = AWSAccountConfig
// OldAWSCollectionStrategy is the backward-compatible snake_case form of AWSCollectionStrategy,
// used in the legacy integration_config response field for older agents.
type OldAWSCollectionStrategy struct {

View File

@@ -1,64 +0,0 @@
package cloudintegrationtypes
type AzureAccountConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
}
type UpdatableAzureAccountConfig struct {
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
}
type AzurePostableAccountConfig = AzureAccountConfig
type AzureConnectionArtifact struct {
CLICommand string `json:"cliCommand" required:"true"`
CloudPowerShellCommand string `json:"cloudPowerShellCommand" required:"true"`
}
type AzureServiceConfig struct {
Logs *AzureServiceLogsConfig `json:"logs" required:"true"`
Metrics *AzureServiceMetricsConfig `json:"metrics" required:"true"`
}
type AzureServiceLogsConfig struct {
Enabled bool `json:"enabled"`
}
type AzureServiceMetricsConfig struct {
Enabled bool `json:"enabled"`
}
type AzureTelemetryCollectionStrategy struct {
//https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-providers-and-types
ResourceProvider string `json:"resourceProvider" required:"true"`
ResourceType string `json:"resourceType" required:"true"`
Metrics *AzureMetricsCollectionStrategy `json:"metrics,omitempty" required:"false" nullable:"false"`
Logs *AzureLogsCollectionStrategy `json:"logs,omitempty" required:"false" nullable:"false"`
}
// AzureMetricsCollectionStrategy no additional config required for metrics, will be added in future as required.
type AzureMetricsCollectionStrategy struct{}
type AzureLogsCollectionStrategy struct {
// List of categories to enable for diagnostic settings, to start with it will have 'allLogs' and no filtering.
CategoryGroups []string `json:"categoryGroups" required:"true" nullable:"false"`
}
type AzureIntegrationConfig struct {
DeploymentRegion string `json:"deploymentRegion" required:"true"`
ResourceGroups []string `json:"resourceGroups" required:"true" nullable:"false"`
TelemetryCollectionStrategy []*AzureTelemetryCollectionStrategy `json:"telemetryCollectionStrategy" required:"true" nullable:"false"`
}
func NewAzureIntegrationConfig(
deploymentRegion string,
resourceGroups []string,
strategies []*AzureTelemetryCollectionStrategy,
) *AzureIntegrationConfig {
return &AzureIntegrationConfig{
DeploymentRegion: deploymentRegion,
ResourceGroups: resourceGroups,
TelemetryCollectionStrategy: strategies,
}
}

View File

@@ -165,13 +165,3 @@ func validateAWSRegion(region string) error {
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid AWS region: %s", region)
}
func validateAzureRegion(region string) error {
for _, r := range SupportedRegions[CloudProviderTypeAzure] {
if r.StringValue() == region {
return nil
}
}
return errors.NewInvalidInputf(ErrCodeInvalidCloudRegion, "invalid Azure region: %s", region)
}

View File

@@ -21,8 +21,8 @@ type CloudIntegrationService struct {
}
type ServiceConfig struct {
AWS *AWSServiceConfig `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureServiceConfig `json:"azure,omitempty" required:"false" nullable:"false"`
// required till new providers are added
AWS *AWSServiceConfig `json:"aws" required:"true" nullable:"false"`
}
// ServiceMetadata helps to quickly list available services and whether it is enabled or not.
@@ -88,8 +88,7 @@ type DataCollected struct {
// TelemetryCollectionStrategy is cloud provider specific configuration for signal collection,
// this is used by agent to understand the nitty-gritty for collecting telemetry for the cloud provider.
type TelemetryCollectionStrategy struct {
AWS *AWSTelemetryCollectionStrategy `json:"aws,omitempty" required:"false" nullable:"false"`
Azure *AzureTelemetryCollectionStrategy `json:"azure,omitempty" required:"false" nullable:"false"`
AWS *AWSTelemetryCollectionStrategy `json:"aws" required:"true" nullable:"false"`
}
// Assets represents the collection of dashboards.
@@ -123,18 +122,7 @@ type Dashboard struct {
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, provider CloudProviderType, config *ServiceConfig) (*CloudIntegrationService, error) {
switch provider {
case CloudProviderTypeAWS:
if config.AWS == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "AWS config is required for AWS service")
}
case CloudProviderTypeAzure:
if config.Azure == nil {
return nil, errors.NewInvalidInputf(ErrCodeInvalidInput, "Azure config is required for Azure service")
}
}
func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.UUID, config *ServiceConfig) *CloudIntegrationService {
return &CloudIntegrationService{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -146,7 +134,7 @@ func NewCloudIntegrationService(serviceID ServiceID, cloudIntegrationID valuer.U
Type: serviceID,
Config: config,
CloudIntegrationID: cloudIntegrationID,
}, nil
}
}
func NewCloudIntegrationServiceFromStorable(stored *StorableCloudIntegrationService, config *ServiceConfig) *CloudIntegrationService {
@@ -203,22 +191,6 @@ func NewServiceConfigFromJSON(provider CloudProviderType, jsonString string) (*S
}
return &ServiceConfig{AWS: awsServiceConfig}, nil
case CloudProviderTypeAzure:
azureServiceConfig := new(AzureServiceConfig)
if storableServiceConfig.Azure.Logs != nil {
azureServiceConfig.Logs = &AzureServiceLogsConfig{
Enabled: storableServiceConfig.Azure.Logs.Enabled,
}
}
if storableServiceConfig.Azure.Metrics != nil {
azureServiceConfig.Metrics = &AzureServiceMetricsConfig{
Enabled: storableServiceConfig.Azure.Metrics.Enabled,
}
}
return &ServiceConfig{Azure: azureServiceConfig}, nil
default:
return nil, errors.NewInvalidInputf(ErrCodeCloudProviderInvalidInput, "invalid cloud provider: %s", provider.StringValue())
}
@@ -256,10 +228,6 @@ func (config *ServiceConfig) IsServiceEnabled(provider CloudProviderType) bool {
logsEnabled := config.AWS.Logs != nil && config.AWS.Logs.Enabled
metricsEnabled := config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
return logsEnabled || metricsEnabled
case CloudProviderTypeAzure:
logsEnabled := config.Azure.Logs != nil && config.Azure.Logs.Enabled
metricsEnabled := config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
return logsEnabled || metricsEnabled
default:
return false
}
@@ -271,8 +239,6 @@ func (config *ServiceConfig) IsMetricsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Metrics != nil && config.AWS.Metrics.Enabled
case CloudProviderTypeAzure:
return config.Azure.Metrics != nil && config.Azure.Metrics.Enabled
default:
return false
}
@@ -283,8 +249,6 @@ func (config *ServiceConfig) IsLogsEnabled(provider CloudProviderType) bool {
switch provider {
case CloudProviderTypeAWS:
return config.AWS.Logs != nil && config.AWS.Logs.Enabled
case CloudProviderTypeAzure:
return config.Azure.Logs != nil && config.Azure.Logs.Enabled
default:
return false
}
@@ -367,3 +331,4 @@ func GetDashboardsFromAssets(
return dashboards
}

View File

@@ -23,10 +23,6 @@ var (
AWSServiceS3Sync = ServiceID{valuer.NewString("s3sync")}
AWSServiceSNS = ServiceID{valuer.NewString("sns")}
AWSServiceSQS = ServiceID{valuer.NewString("sqs")}
// Azure services.
AzureServiceStorageAccountsBlob = ServiceID{valuer.NewString("storageaccountsblob")}
AzureServiceCDNProfile = ServiceID{valuer.NewString("cdnprofile")}
)
func (ServiceID) Enum() []any {
@@ -44,8 +40,6 @@ func (ServiceID) Enum() []any {
AWSServiceS3Sync,
AWSServiceSNS,
AWSServiceSQS,
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
}
}
@@ -66,10 +60,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AWSServiceSNS,
AWSServiceSQS,
},
CloudProviderTypeAzure: {
AzureServiceStorageAccountsBlob,
AzureServiceCDNProfile,
},
}
func NewServiceID(provider CloudProviderType, service string) (ServiceID, error) {

View File

@@ -12,14 +12,13 @@ import (
var (
// Templates is a list of all the templates that are supported by the emailing service.
// This list should be updated whenever a new template is added.
Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameResetPassword, TemplateNameAlertEmailNotification}
Templates = []TemplateName{TemplateNameInvitationEmail, TemplateNameResetPassword}
)
var (
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation")}
TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password")}
TemplateNameAPIKeyEvent = TemplateName{valuer.NewString("api_key_event")}
TemplateNameAlertEmailNotification = TemplateName{valuer.NewString("alert_email_notification")}
TemplateNameInvitationEmail = TemplateName{valuer.NewString("invitation")}
TemplateNameResetPassword = TemplateName{valuer.NewString("reset_password")}
TemplateNameAPIKeyEvent = TemplateName{valuer.NewString("api_key_event")}
)
type TemplateName struct{ valuer.String }
@@ -32,8 +31,6 @@ func NewTemplateName(name string) (TemplateName, error) {
return TemplateNameResetPassword, nil
case TemplateNameAPIKeyEvent.StringValue():
return TemplateNameAPIKeyEvent, nil
case TemplateNameAlertEmailNotification.StringValue():
return TemplateNameAlertEmailNotification, nil
default:
return TemplateName{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid template name: %s", name)
}

View File

@@ -77,28 +77,6 @@ func (c CompareOperator) Normalize() CompareOperator {
}
}
// Literal returns the canonical literal (string) form of the operator.
func (c CompareOperator) Literal() string {
switch c.Normalize() {
case ValueIsAbove:
return ValueIsAboveLiteral.StringValue()
case ValueIsBelow:
return ValueIsBelowLiteral.StringValue()
case ValueIsEq:
return ValueIsEqLiteral.StringValue()
case ValueIsNotEq:
return ValueIsNotEqLiteral.StringValue()
case ValueAboveOrEq:
return ValueAboveOrEqLiteral.StringValue()
case ValueBelowOrEq:
return ValueBelowOrEqLiteral.StringValue()
case ValueOutsideBounds:
return ValueOutsideBoundsLiteral.StringValue()
default:
return c.StringValue()
}
}
func (c CompareOperator) Validate() error {
switch c {
case ValueIsAbove,

View File

@@ -56,24 +56,6 @@ func (m MatchType) Normalize() MatchType {
}
}
// Literal returns the canonical literal (string) form of the match type.
func (m MatchType) Literal() string {
switch m.Normalize() {
case AtleastOnce:
return AtleastOnceLiteral.StringValue()
case AllTheTimes:
return AllTheTimesLiteral.StringValue()
case OnAverage:
return OnAverageLiteral.StringValue()
case InTotal:
return InTotalLiteral.StringValue()
case Last:
return LastLiteral.StringValue()
default:
return m.StringValue()
}
}
func (m MatchType) Validate() error {
switch m {
case

View File

@@ -24,10 +24,6 @@ type Sample struct {
RecoveryTarget *float64
TargetUnit string
// CompareOperator and MatchType carry the threshold evaluation context
CompareOperator CompareOperator
MatchType MatchType
}
func (s Sample) String() string {

View File

@@ -1,17 +0,0 @@
package ruletypes
var CustomTemplatingAnnotations = []string{
AnnotationTitleTemplate,
AnnotationBodyTemplate,
}
// IsCustomTemplatingAnnotation checks if the given annotation is a custom templating annotation
// in order to avoid expanding them in the rule manager layer.
func IsCustomTemplatingAnnotation(name string) bool {
for _, annotation := range CustomTemplatingAnnotations {
if annotation == name {
return true
}
}
return false
}

View File

@@ -167,8 +167,6 @@ func (r BasicRuleThresholds) Eval(s *qbtypes.TimeSeries, unit string, evalData E
smpl.RecoveryTarget = threshold.RecoveryTarget
}
smpl.TargetUnit = threshold.TargetUnit
smpl.CompareOperator = threshold.CompareOperator
smpl.MatchType = threshold.MatchType
resultVector = append(resultVector, smpl)
continue
} else if evalData.SendUnmatched {
@@ -178,12 +176,10 @@ func (r BasicRuleThresholds) Eval(s *qbtypes.TimeSeries, unit string, evalData E
}
// prepare the sample with the first point of the series
smpl := Sample{
Point: Point{T: series.Values[0].Timestamp, V: series.Values[0].Value},
Metric: PrepareSampleLabelsForRule(series.Labels, threshold.Name),
Target: *threshold.TargetValue,
TargetUnit: threshold.TargetUnit,
CompareOperator: threshold.CompareOperator,
MatchType: threshold.MatchType,
Point: Point{T: series.Values[0].Timestamp, V: series.Values[0].Value},
Metric: PrepareSampleLabelsForRule(series.Labels, threshold.Name),
Target: *threshold.TargetValue,
TargetUnit: threshold.TargetUnit,
}
if threshold.RecoveryTarget != nil {
smpl.RecoveryTarget = threshold.RecoveryTarget
@@ -205,8 +201,6 @@ func (r BasicRuleThresholds) Eval(s *qbtypes.TimeSeries, unit string, evalData E
smpl.Target = *threshold.TargetValue
smpl.RecoveryTarget = threshold.RecoveryTarget
smpl.TargetUnit = threshold.TargetUnit
smpl.CompareOperator = threshold.CompareOperator
smpl.MatchType = threshold.MatchType
// IsRecovering to notify that metrics is in recovery stage
smpl.IsRecovering = true
resultVector = append(resultVector, smpl)

View File

@@ -1,120 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{.Title}}</title>
<style>
code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
pre {
background: #f0f0f0;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
overflow-x: auto;
white-space: pre;
}
pre code {
background: none;
padding: 0;
border-radius: 0;
font-size: inherit;
}
table:not([role="presentation"]) {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table:not([role="presentation"]) th {
font-weight: 600;
text-align: left;
padding: 8px 12px;
border-bottom: 2px solid #d0d0d0;
}
table:not([role="presentation"]) td {
padding: 8px 12px;
border-bottom: 1px solid #e8e8e8;
}
table:not([role="presentation"]) tr:last-child td {
border-bottom: none;
}
</style>
</head>
<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#333;background:#fff">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:#fff">
<tr>
<td align="center" style="padding:0">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;width:100%;border:1px solid #e2e2e2;border-radius:12px;overflow:hidden">
<tr>
<td align="center" style="padding:20px 20px 12px">
<h2 style="margin:0 0 8px;font-size:20px;color:#333">{{.Title}}</h2>
<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.TotalResolved}} | Resolved: <strong style="color:#38a169">{{.Alert.TotalResolved}}</strong>{{end}}
</p>
</td>
</tr>
<tr>
<td style="padding:0 20px">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr><td style="border-top:1px solid #e2e2e2;font-size:0;line-height:0" height="1">&nbsp;</td></tr>
</table>
</td>
</tr>
{{range .Bodies}}
<tr>
<td style="padding:8px 20px">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding:16px;background:#fafafa;border:1px solid #e8e8e8;border-radius:6px">
{{.}}
</td>
</tr>
</table>
</td>
</tr>
{{end}}
{{if .NotificationTemplateData.ExternalURL}}
<tr>
<td style="padding:16px 20px">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
<a href="{{.NotificationTemplateData.ExternalURL}}" target="_blank" style="display:inline-block;padding:12px 32px;font-size:14px;font-weight:600;color:#fff;background:#4E74F8;text-decoration:none;border-radius:4px">
View in SigNoz
</a>
</td>
</tr>
</table>
</td>
</tr>
{{end}}
<tr>
<td align="center" style="padding:8px 16px 16px">
<p style="margin:0;font-size:12px;color:#999;line-height:1.5">
Sent by SigNoz AlertManager
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -23,7 +23,6 @@ pytest_plugins = [
"fixtures.notification_channel",
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.maildev",
"fixtures.seeder",
]

View File

@@ -4,7 +4,6 @@ import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from urllib.parse import urlparse
import pytest
import requests
@@ -14,7 +13,6 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.logger import setup_logger
from fixtures.logs import Logs
from fixtures.maildev import verify_email_received
from fixtures.metrics import Metrics
from fixtures.traces import Traces
@@ -220,118 +218,3 @@ def update_rule_channel_name(rule_data: dict, channel_name: str):
# loop over all the sepcs and update the channels
for spec in thresholds["spec"]:
spec["channels"] = [channel_name]
def _is_json_subset(subset, superset) -> bool:
"""Check if subset is contained within superset recursively.
- For dicts: all keys in subset must exist in superset with matching values
- For lists: all items in subset must be present in superset
- For scalars: exact equality
"""
if isinstance(subset, dict):
if not isinstance(superset, dict):
return False
return all(key in superset and _is_json_subset(value, superset[key]) for key, value in subset.items())
if isinstance(subset, list):
if not isinstance(superset, list):
return False
return all(any(_is_json_subset(sub_item, sup_item) for sup_item in superset) for sub_item in subset)
return subset == superset
def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
path = validation_data["path"]
json_body = validation_data["json_body"]
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
res = requests.post(url, json={"method": "POST", "url": path}, timeout=5)
assert res.status_code == HTTPStatus.OK, f"Failed to find requests for path {path}, status code: {res.status_code}, response: {res.text}"
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
# logger.info("Webhook request body: %s", json.dumps(body, indent=2))
if _is_json_subset(json_body, body):
return True
return False
def _check_notification_validation(
validation: types.NotificationValidation,
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
) -> bool:
"""Dispatch a single validation check to the appropriate verifier."""
if validation.destination_type == "webhook":
return verify_webhook_notification_expectation(notification_channel, validation.validation_data)
if validation.destination_type == "email":
return verify_email_received(maildev, validation.validation_data)
raise ValueError(f"Invalid destination type: {validation.destination_type}")
def verify_notification_expectation(
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
expected_notification: types.AMNotificationExpectation,
) -> bool:
"""Poll for expected notifications across webhook and email channels."""
time_to_wait = datetime.now() + timedelta(seconds=expected_notification.wait_time_seconds)
while datetime.now() < time_to_wait:
all_found = all(_check_notification_validation(v, notification_channel, maildev) for v in expected_notification.notification_validations)
if expected_notification.should_notify and all_found:
logger.info("All expected notifications found")
return True
time.sleep(1)
# Timeout reached
if not expected_notification.should_notify:
# Verify no notifications were received
for validation in expected_notification.notification_validations:
found = _check_notification_validation(validation, notification_channel, maildev)
assert not found, f"Expected no notification but found one for {validation.destination_type} with data {validation.validation_data}"
logger.info("No notifications found, as expected")
return True
# Expected notifications but didn't get them all — report missing
missing = [v for v in expected_notification.notification_validations if not _check_notification_validation(v, notification_channel, maildev)]
assert len(missing) == 0, f"Expected all notifications to be found but missing: {missing}"
return True
def update_raw_channel_config(
channel_config: dict,
channel_name: str,
notification_channel: types.TestContainerDocker,
) -> dict:
"""
Updates the channel config to point to the given wiremock
notification_channel container to receive notifications.
"""
config = channel_config.copy()
config["name"] = channel_name
url_field_map = {
"slack_configs": "api_url",
"msteamsv2_configs": "webhook_url",
"webhook_configs": "url",
"pagerduty_configs": "url",
"opsgenie_configs": "api_url",
}
for config_key, url_field in url_field_map.items():
if config_key in config:
for entry in config[config_key]:
if url_field in entry:
original_url = entry[url_field]
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
return config

View File

@@ -124,19 +124,14 @@ def gateway(
@pytest.fixture(name="make_http_mocks", scope="function")
def make_http_mocks(
request: pytest.FixtureRequest,
) -> Callable[[types.TestContainerDocker, list[Mapping]], None]:
def make_http_mocks() -> Callable[[types.TestContainerDocker, list[Mapping]], None]:
def _make_http_mocks(container: types.TestContainerDocker, mappings: list[Mapping]) -> None:
Config.base_url = container.host_configs["8080"].get("/__admin")
for mapping in mappings:
Mappings.create_mapping(mapping=mapping)
def cleanup():
Mappings.delete_all_mappings()
Requests.reset_request_journal()
yield _make_http_mocks
request.addfinalizer(cleanup)
return _make_http_mocks
Mappings.delete_all_mappings()
Requests.reset_request_journal()

View File

@@ -1,122 +0,0 @@
import json
from http import HTTPStatus
import docker
import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from fixtures import reuse, types
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@pytest.fixture(name="maildev", scope="package")
def maildev(network: Network, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.TestContainerDocker:
"""
Package-scoped fixture for MailDev container.
Provides SMTP (port 1025) and HTTP API (port 1080) for email testing.
"""
def create() -> types.TestContainerDocker:
container = DockerContainer(image="maildev/maildev:2.2.1")
container.with_exposed_ports(1025, 1080)
container.with_network(network=network)
container.start()
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
host_configs={
"1025": types.TestContainerUrlConfig(
scheme="smtp",
address=container.get_container_host_ip(),
port=container.get_exposed_port(1025),
),
"1080": types.TestContainerUrlConfig(
scheme="http",
address=container.get_container_host_ip(),
port=container.get_exposed_port(1080),
),
},
container_configs={
"1025": types.TestContainerUrlConfig(
scheme="smtp",
address=container.get_wrapped_container().name,
port=1025,
),
"1080": types.TestContainerUrlConfig(
scheme="http",
address=container.get_wrapped_container().name,
port=1080,
),
},
)
def delete(container: types.TestContainerDocker):
client = docker.from_env()
try:
client.containers.get(container_id=container.id).stop()
client.containers.get(container_id=container.id).remove(v=True)
except docker.errors.NotFound:
logger.info(
"Skipping removal of MailDev, MailDev(%s) not found. Maybe it was manually removed?",
{"id": container.id},
)
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
return reuse.wrap(
request,
pytestconfig,
"maildev",
lambda: types.TestContainerDocker(id="", host_configs={}, container_configs={}),
create,
delete,
restore,
)
def get_all_mails(_maildev: types.TestContainerDocker) -> list[dict]:
"""
Fetches all emails from the MailDev HTTP API.
Returns list of dicts with keys: subject, html, text.
"""
url = _maildev.host_configs["1080"].get("/email")
response = requests.get(url, timeout=5)
assert response.status_code == HTTPStatus.OK, f"Failed to fetch emails from MailDev, status code: {response.status_code}, response: {response.text}"
emails = response.json()
# logger.info("Emails: %s", json.dumps(emails, indent=2))
return [
{
"subject": email.get("subject", ""),
"html": email.get("html", ""),
"text": email.get("text", ""),
}
for email in emails
]
def verify_email_received(_maildev: types.TestContainerDocker, filters: dict) -> bool:
"""
Checks if any email in MailDev matches all the given filters.
Filters are matched with exact equality against the email fields (subject, html, text).
Returns True if at least one matching email is found.
"""
emails = get_all_mails(_maildev)
for email in emails:
logger.info("Email: %s", json.dumps(email, indent=2))
if all(key in email and filter_value == email[key] for key, filter_value in filters.items()):
return True
return False
def delete_all_mails(_maildev: types.TestContainerDocker) -> None:
"""
Deletes all emails from the MailDev inbox.
"""
url = _maildev.host_configs["1080"].get("/email/all")
response = requests.delete(url, timeout=5)
assert response.status_code == HTTPStatus.OK, f"Failed to delete emails from MailDev, status code: {response.status_code}, response: {response.text}"

View File

@@ -1,4 +1,3 @@
# pylint: disable=line-too-long
from collections.abc import Callable
from http import HTTPStatus
@@ -16,87 +15,6 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
"""
Default notification channel configs shared across alertmanager tests.
"""
slack_default_config = {
# channel name configured on runtime
"slack_configs": [
{
"api_url": "services/TEAM_ID/BOT_ID/TOKEN_ID", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}
# MSTeams default config
msteams_default_config = {
"msteamsv2_configs": [
{
"webhook_url": "msteams/webhook_url", # base_url configured on runtime
"title": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}',
"text": '{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}',
}
],
}
# pagerduty default config
pagerduty_default_config = {
"pagerduty_configs": [
{
"routing_key": "PagerDutyRoutingKey",
"url": "v2/enqueue", # base_url configured on runtime
"client": "SigNoz Alert Manager",
"client_url": "https://enter-signoz-host-n-port-here/alerts",
"description": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n\t{{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n\t {{" "}}(\n\t {{- with .CommonLabels.Remove .GroupLabels.Names }}\n\t\t{{- range $index, $label := .SortedPairs -}}\n\t\t {{ if $index }}, {{ end }}\n\t\t {{- $label.Name }}="{{ $label.Value -}}"\n\t\t{{- end }}\n\t {{- end -}}\n\t )\n\t{{- end }}',
"details": {
"firing": '{{ template "pagerduty.default.instances" .Alerts.Firing }}',
"num_firing": "{{ .Alerts.Firing | len }}",
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": '{{ template "pagerduty.default.instances" .Alerts.Resolved }}',
},
"source": "SigNoz Alert Manager",
"severity": "{{ (index .Alerts 0).Labels.severity }}",
}
],
}
# opsgenie default config
opsgenie_default_config = {
"opsgenie_configs": [
{
"api_key": "OpsGenieAPIKey",
"api_url": "/", # base_url configured on runtime
"description": '{{ if gt (len .Alerts.Firing) 0 -}}\r\n\tAlerts Firing:\r\n\t{{ range .Alerts.Firing }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}\r\n{{ if gt (len .Alerts.Resolved) 0 -}}\r\n\tAlerts Resolved:\r\n\t{{ range .Alerts.Resolved }}\r\n\t - Message: {{ .Annotations.description }}\r\n\tLabels:\r\n\t{{ range .Labels.SortedPairs -}}\r\n\t\t{{- if ne .Name "ruleId" }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end -}}\r\n\t{{- end }} Annotations:\r\n\t{{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }}\r\n\t{{ end }} Source: {{ .GeneratorURL }}\r\n\t{{ end }}\r\n{{- end }}',
"priority": '{{ if eq (index .Alerts 0).Labels.severity "critical" }}P1{{ else if eq (index .Alerts 0).Labels.severity "warning" }}P2{{ else if eq (index .Alerts 0).Labels.severity "info" }}P3{{ else }}P4{{ end }}',
"message": "{{ .CommonLabels.alertname }}",
"details": {},
}
]
}
# webhook default config
webhook_default_config = {
"webhook_configs": [
{
"url": "webhook/webhook_url", # base_url configured on runtime
}
],
}
# email default config
email_default_config = {
"email_configs": [
{
"to": "test@example.com",
"html": '<html><body>{{ range .Alerts -}}\r\n *Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} - {{ .Labels.severity }}{{ end }}\r\n\r\n *Summary:* {{ .Annotations.summary }}\r\n *Description:* {{ .Annotations.description }}\r\n *RelatedLogs:* {{ if gt (len .Annotations.related_logs) 0 -}} View in <{{ .Annotations.related_logs }}|logs explorer> {{- end}}\r\n *RelatedTraces:* {{ if gt (len .Annotations.related_traces) 0 -}} View in <{{ .Annotations.related_traces }}|traces explorer> {{- end}}\r\n\r\n *Details:*\r\n {{ range .Labels.SortedPairs -}}\r\n {{- if ne .Name "ruleId" -}}\r\n \u2022 *{{ .Name }}:* {{ .Value }}\r\n {{ end -}}\r\n {{ end -}}\r\n{{ end }}</body></html>',
"headers": {
"Subject": '[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }} for {{ .CommonLabels.job }}\n {{- if gt (len .CommonLabels) (len .GroupLabels) -}}\n {{" "}}(\n {{- with .CommonLabels.Remove .GroupLabels.Names }}\n {{- range $index, $label := .SortedPairs -}}\n {{ if $index }}, {{ end }}\n {{- $label.Name }}="{{ $label.Value -}}"\n {{- end }}\n {{- end -}}\n )\n {{- end }}'
},
}
],
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel(
network: Network,
@@ -149,27 +67,6 @@ def notification_channel(
)
@pytest.fixture(name="create_notification_channel", scope="function")
def create_notification_channel(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> Callable[[dict], str]:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _create_notification_channel(channel_config: dict) -> str:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels"),
json=channel_config,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"Failed to create channel, Response: {response.text} Response status: {response.status_code}"
return response.json()["data"]["id"]
return _create_notification_channel
@pytest.fixture(name="create_webhook_notification_channel", scope="function")
def create_webhook_notification_channel(
signoz: types.SigNoz,

View File

@@ -192,40 +192,3 @@ class AlertTestCase:
alert_data: list[AlertData]
# list of alert expectations for the test case
alert_expectation: AlertExpectation
@dataclass(frozen=True)
class NotificationValidation:
# destination type of the notification, either webhook or email
# slack, msteams, pagerduty, opsgenie, webhook channels send notifications through webhook
# email channels send notifications through email
destination_type: Literal["webhook", "email"]
# validation data for validating the received notification payload
validation_data: dict[str, any]
@dataclass(frozen=True)
class AMNotificationExpectation:
# whether we expect any notifications to be fired or not, false when testing downtime scenarios
# or don't expect any notifications to be fired in given time period
should_notify: bool
# seconds to wait for the notifications to be fired, if no
# notifications are fired in the expected time, the test will fail
wait_time_seconds: int
# list of notifications to expect, as a single rule can trigger multiple notifications
# spanning across different notifiers
notification_validations: list[NotificationValidation]
@dataclass(frozen=True)
class AlertManagerNotificationTestCase:
# name of the test case
name: str
# path to the rule file in testdata directory
rule_path: str
# list of alert data that will be inserted into the database for the rule to be triggered
alert_data: list[AlertData]
# configuration for the notification channel
channel_config: dict[str, any]
# notification expectations for the test case
notification_expectation: AMNotificationExpectation

View File

@@ -39,7 +39,5 @@ def test_teardown(
idp: types.TestContainerIDP, # pylint: disable=unused-argument
create_user_admin: types.Operation, # pylint: disable=unused-argument
migrator: types.Operation, # pylint: disable=unused-argument
maildev: types.TestContainerDocker, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker, # pylint: disable=unused-argument
) -> None:
pass

View File

@@ -1,411 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.logger import setup_logger
from fixtures.maildev import delete_all_mails
from fixtures.notification_channel import (
email_default_config,
msteams_default_config,
opsgenie_default_config,
pagerduty_default_config,
slack_default_config,
webhook_default_config,
)
# tests to verify the notifiers sending out the notifications with expected content
NOTIFIERS_TEST = [
types.AlertManagerNotificationTestCase(
name="slack_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=slack_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
# extra wait for alertmanager server setup
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/services/TEAM_ID/BOT_ID/TOKEN_ID",
"json_body": {
"username": "Alertmanager",
"attachments": [
{
"title": '[FIRING:1] threshold_above_at_least_once for (alertname="threshold_above_at_least_once", severity="critical", threshold.name="critical")',
"text": "*Alert:* threshold_above_at_least_once - critical\r\n\r\n *Summary:* This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\r\n *Description:* This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\r\n *RelatedLogs:* \r\n *RelatedTraces:* \r\n\r\n *Details:*\r\n • *alertname:* threshold_above_at_least_once\r\n • *severity:* critical\r\n • *threshold.name:* critical\r\n ",
"color": "danger",
"mrkdwn_in": ["fallback", "pretext", "text"],
}
],
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="msteams_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=msteams_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/msteams/webhook_url",
"json_body": {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.2",
"body": [
{
"type": "TextBlock",
"text": '[FIRING:1] threshold_above_at_least_once for (alertname="threshold_above_at_least_once", severity="critical", threshold.name="critical")',
"weight": "Bolder",
"size": "Medium",
"wrap": True,
"style": "heading",
"color": "Attention",
},
{
"type": "TextBlock",
"text": "Alerts",
"weight": "Bolder",
"size": "Medium",
"wrap": True,
"color": "Attention",
},
{
"type": "TextBlock",
"text": "Labels",
"weight": "Bolder",
"size": "Medium",
},
{
"type": "FactSet",
"text": "",
"facts": [
{
"title": "threshold.name",
"value": "critical",
}
],
},
{
"type": "TextBlock",
"text": "Annotations",
"weight": "Bolder",
"size": "Medium",
},
{
"type": "FactSet",
"text": "",
"facts": [
{
"title": "threshold.value",
"value": "10",
},
{
"title": "compare_op",
"value": "above",
},
{
"title": "match_type",
"value": "at_least_once",
},
{"title": "value", "value": "15"},
{
"title": "description",
"value": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
},
],
},
],
"msteams": {"width": "full"},
"actions": [
{
"type": "Action.OpenUrl",
"title": "View Alert",
}
],
},
}
],
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="pagerduty_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=pagerduty_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v2/enqueue",
"json_body": {
"routing_key": "PagerDutyRoutingKey",
"event_action": "trigger",
"payload": {
"summary": '[FIRING:1] threshold_above_at_least_once for (alertname="threshold_above_at_least_once", severity="critical", threshold.name="critical")',
"source": "SigNoz Alert Manager",
"severity": "critical",
"custom_details": {
"firing": {
"Annotations": [
"compare_op = above",
{"description = This alert is fired when the defined metric (current value": "15) crosses the threshold (10)"},
"match_type = at_least_once",
"threshold.value = 10",
"value = 15",
],
"Labels": [
"alertname = threshold_above_at_least_once",
"severity = critical",
"threshold.name = critical",
],
}
},
},
"client": "SigNoz Alert Manager",
"client_url": "https://enter-signoz-host-n-port-here/alerts",
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="opsgenie_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=opsgenie_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v2/alerts",
"json_body": {
"message": "threshold_above_at_least_once",
"description": "Alerts Firing:\r\n\t\r\n\t - Message: This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\r\n\tLabels:\r\n\t - alertname = threshold_above_at_least_once\r\n\t - severity = critical\r\n\t - threshold.name = critical\r\n\t Annotations:\r\n\t - compare_op = above\r\n\t - description = This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\r\n\t - match_type = at_least_once\r\n\t - summary = This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\r\n\t - threshold.value = 10\r\n\t - value = 15\r\n\t Source: \r\n\t\r\n",
"details": {
"alertname": "threshold_above_at_least_once",
"severity": "critical",
"threshold.name": "critical",
},
"priority": "P1",
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="webhook_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=webhook_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/webhook/webhook_url",
"json_body": {
"status": "firing",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "threshold_above_at_least_once",
"severity": "critical",
"threshold.name": "critical",
},
"annotations": {
"compare_op": "above",
"description": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
"match_type": "at_least_once",
"summary": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
"threshold.value": "10",
"value": "15",
},
}
],
"commonLabels": {
"alertname": "threshold_above_at_least_once",
"severity": "critical",
"threshold.name": "critical",
},
"commonAnnotations": {
"compare_op": "above",
"description": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
"match_type": "at_least_once",
"summary": "This alert is fired when the defined metric (current value: 15) crosses the threshold (10)",
"threshold.value": "10",
"value": "15",
},
},
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="email_notifier_default_templating",
rule_path="alerts/test_scenarios/threshold_above_at_least_once/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl",
),
],
channel_config=email_default_config,
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="email",
validation_data={
"subject": '[FIRING:1] threshold_above_at_least_once for (alertname="threshold_above_at_least_once", severity="critical", threshold.name="critical")',
"html": "<html><body>*Alert:* threshold_above_at_least_once - critical\n\n *Summary:* This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\n *Description:* This alert is fired when the defined metric (current value: 15) crosses the threshold (10)\n *RelatedLogs:* \n *RelatedTraces:* \n\n *Details:*\n \u2022 *alertname:* threshold_above_at_least_once\n \u2022 *severity:* critical\n \u2022 *threshold.name:* critical\n </body></html>",
},
),
],
),
),
]
logger = setup_logger(__name__)
@pytest.mark.parametrize(
"notifier_test_case",
NOTIFIERS_TEST,
ids=lambda notifier_test_case: notifier_test_case.name,
)
def test_notifier_templating(
# wiremock container for webhook notifications
notification_channel: types.TestContainerDocker,
# function to create wiremock mocks
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
# function to create alert rule
create_alert_rule: Callable[[dict], str],
# Alert data insertion related fixture
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
# Mail dev container for email verification
maildev: types.TestContainerDocker,
# test case from parametrize
notifier_test_case: types.AlertManagerNotificationTestCase,
):
# generate unique channel name
channel_name = str(uuid.uuid4())
# update channel config: set name and rewrite URLs to wiremock
channel_config = update_raw_channel_config(notifier_test_case.channel_config, channel_name, notification_channel)
logger.info("Channel config: %s", {"channel_config": channel_config})
# setup wiremock mocks for webhook-based notification validations
webhook_validations = [v for v in notifier_test_case.notification_expectation.notification_validations if v.destination_type == "webhook"]
if len(webhook_validations) > 0:
mock_mappings = [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url=v.validation_data["path"]),
response=MappingResponse(status=200, json_body={}),
persistent=False,
)
for v in webhook_validations
]
make_http_mocks(notification_channel, mock_mappings)
logger.info("Mock mappings created")
# clear mails if any destination is email
if any(v.destination_type == "email" for v in notifier_test_case.notification_expectation.notification_validations):
delete_all_mails(maildev)
logger.info("Mails deleted")
# create notification channel
create_notification_channel(channel_config)
logger.info("Channel created with name: %s", {"channel_name": channel_name})
# insert alert data
insert_alert_data(
notifier_test_case.alert_data,
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
# create alert rule
rule_path = get_testdata_file_path(notifier_test_case.rule_path)
with open(rule_path, encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
rule_id = create_alert_rule(rule_data)
logger.info("rule created: %s", {"rule_id": rule_id, "rule_name": rule_data["alert"]})
# verify notification expectations
verify_notification_expectation(
notification_channel,
maildev,
notifier_test_case.notification_expectation,
)

File diff suppressed because one or more lines are too long

View File

@@ -1,42 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
maildev: types.TestContainerDocker,
notification_channel: types.TestContainerDocker,
) -> types.SigNoz:
"""
Package-scoped fixture for setting up SigNoz.
Overrides SMTP, PagerDuty, and OpsGenie URLs to point to test containers.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
env_overrides={
# SMTP config for email notifications via maildev
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__SMARTHOST": f"{maildev.container_configs['1025'].address}:{maildev.container_configs['1025'].port}",
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__REQUIRE__TLS": "false",
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_SMTP__FROM": "alertmanager@signoz.io",
# PagerDuty API URL -> wiremock (default: https://events.pagerduty.com/v2/enqueue)
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_PAGERDUTY__URL": notification_channel.container_configs["8080"].get("/v2/enqueue"),
# OpsGenie API URL -> wiremock (default: https://api.opsgenie.com/)
"SIGNOZ_ALERTMANAGER_SIGNOZ_GLOBAL_OPSGENIE__API__URL": notification_channel.container_configs["8080"].get("/"),
},
)

View File

@@ -1,20 +0,0 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "User login successful", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Database connection established", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "API request received", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:04:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:04:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:05:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:05:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:06:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:06:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: insufficient funds", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:07:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:07:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:08:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "Response sent to client", "severity_text": "INFO" }
{ "timestamp": "2026-01-29T10:08:30.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: invalid token", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:09:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: card declined", "severity_text": "ERROR" }
{ "timestamp": "2026-01-29T10:10:00.000000Z", "resources": { "service.name": "payment-service" }, "attributes": { "code.file": "payment_handler.py" }, "body": "payment failure: gateway timeout", "severity_text": "ERROR" }

View File

@@ -1,71 +0,0 @@
{
"alert": "content_templating_logs",
"ruleType": "threshold_rule",
"alertType": "LOGS_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"filter": {
"expression": "body CONTAINS 'payment failure'"
},
"aggregations": [
{
"expression": "count()"
}
],
"groupBy": [
{"name": "service.name", "fieldContext": "resource"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "Payment failure spike detected on $service_name",
"summary": "Payment failures elevated on $service_name",
"_title_template": "[$alert.status] Payment failure spike in $labels.service.name",
"_body_template": "**Severity:** $rule.severity\n**Status:** $alert.status\n\n**Service:** $labels.service.name\n\n**Condition ($labels.threshold.name):**\n- **Current:** $alert.value\n- **Threshold:** $rule.threshold.op $rule.threshold.value\n\n**Description:** Payment failures observed at $alert.value over the evaluation window, crossing the $labels.threshold.name threshold of $rule.threshold.op $rule.threshold.value on $labels.service.name. Investigate downstream payment processor health.\n\n**Runbook:** https://signoz.io/docs/runbooks/payment-failure-spike"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,12 +0,0 @@
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:01:00+00:00","value":80,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:02:00+00:00","value":95,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:03:00+00:00","value":110,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:04:00+00:00","value":120,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:05:00+00:00","value":125,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:06:00+00:00","value":130,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:07:00+00:00","value":135,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:08:00+00:00","value":140,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:09:00+00:00","value":145,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:10:00+00:00","value":150,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:11:00+00:00","value":155,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"container_memory_bytes_content_templating","labels":{"namespace":"production","pod":"checkout-7d9c8b5f4-x2k9p","container":"checkout","node":"ip-10-0-1-23","severity":"critical","service":"checkout"},"timestamp":"2026-01-29T10:12:00+00:00","value":160,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"bytes","env":"default","resource_attrs":{},"scope_attrs":{}}

View File

@@ -1,74 +0,0 @@
{
"alert": "content_templating_metrics",
"ruleType": "threshold_rule",
"alertType": "METRIC_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 100,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [
{
"metricName": "container_memory_bytes_content_templating",
"timeAggregation": "avg",
"spaceAggregation": "max"
}
],
"groupBy": [
{"name": "namespace", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "pod", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "container", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "node", "fieldContext": "attribute", "fieldDataType": "string"},
{"name": "severity", "fieldContext": "attribute", "fieldDataType": "string"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "Container $container in pod $pod ($namespace) exceeded memory threshold",
"summary": "High container memory in $namespace/$pod",
"_title_template": "[$alert.status] High container memory in $labels.namespace/$labels.pod",
"_body_template": "**Severity:** $rule.severity\n**Status:** $alert.status\n\n**Pod Details:**\n- **Namespace:** $labels.namespace\n- **Pod:** $labels.pod\n- **Container:** $labels.container\n- **Node:** $labels.node\n\n**Condition ($labels.threshold.name):**\n- **Current:** $alert.value\n- **Threshold:** $rule.threshold.op $rule.threshold.value\n\n**Description:** Container $labels.container in pod $labels.pod ($labels.namespace) has memory usage at $alert.value, which crossed the $labels.threshold.name threshold of $rule.threshold.op $rule.threshold.value. Immediate investigation is recommended to prevent OOMKill.\n\n**Runbook:** https://signoz.io/docs/runbooks/container-memory-near-limit"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,20 +0,0 @@
{ "timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT1.2S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a1", "span_id": "c1b2c3d4e5f6a7b8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT1.4S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a2", "span_id": "c2b3c4d5e6f7a8b9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT1.6S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a3", "span_id": "c3b4c5d6e7f8a9b0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT1.8S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a4", "span_id": "c4b5c6d7e8f9a0b1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a5", "span_id": "c5b6c7d8e9f0a1b2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a6", "span_id": "c6b7c8d9e0f1a2b3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a7", "span_id": "c7b8c9d0e1f2a3b4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a8", "span_id": "c8b9c0d1e2f3a4b5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:04:00.000000Z", "duration": "PT2.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6a9", "span_id": "c9b0c1d2e3f4a5b6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:04:30.000000Z", "duration": "PT3.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b1", "span_id": "d1c2d3e4f5a6b7c8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:05:00.000000Z", "duration": "PT3.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b2", "span_id": "d2c3d4e5f6a7b8c9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:05:30.000000Z", "duration": "PT3.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b3", "span_id": "d3c4d5e6f7a8b9c0", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:06:00.000000Z", "duration": "PT3.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b4", "span_id": "d4c5d6e7f8a9b0c1", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:06:30.000000Z", "duration": "PT3.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b5", "span_id": "d5c6d7e8f9a0b1c2", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:07:00.000000Z", "duration": "PT4.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b6", "span_id": "d6c7d8e9f0a1b2c3", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:07:30.000000Z", "duration": "PT4.3S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b7", "span_id": "d7c8d9e0f1a2b3c4", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:08:00.000000Z", "duration": "PT4.5S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b8", "span_id": "d8c9d0e1f2a3b4c5", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:08:30.000000Z", "duration": "PT4.7S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6b9", "span_id": "d9c0d1e2f3a4b5c6", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:09:00.000000Z", "duration": "PT4.9S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c1", "span_id": "e1d2e3f4a5b6c7d8", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }
{ "timestamp": "2026-01-29T10:10:00.000000Z", "duration": "PT5.1S", "trace_id": "591f6d3d6b0a1f9e8a71b2c3d4e5f6c2", "span_id": "e2d3e4f5a6b7c8d9", "parent_span_id": "", "name": "POST /checkout", "kind": 2, "status_code": 1, "status_message": "", "resources": { "deployment.environment": "production", "service.name": "checkout-service", "os.type": "linux", "host.name": "ip-10-0-1-23" }, "attributes": { "net.transport": "IP.TCP", "http.scheme": "http", "http.user_agent": "Integration Test", "http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/checkout" } }

View File

@@ -1,73 +0,0 @@
{
"alert": "content_templating_traces",
"ruleType": "threshold_rule",
"alertType": "TRACES_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 1,
"matchType": "1",
"op": "1",
"channels": [
"test channel"
],
"targetUnit": "s"
}
]
},
"compositeQuery": {
"queryType": "builder",
"unit": "ns",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "http.request.path = '/checkout'"
},
"aggregations": [
{
"expression": "p90(duration_nano)"
}
],
"groupBy": [
{"name": "service.name", "fieldContext": "resource"}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "p90 latency high on $service_name",
"summary": "p90 latency exceeded threshold on $service_name",
"_title_template": "[$alert.status] p90 latency high on $labels.service_name",
"_body_template": "**Severity:** $rule.severity\n**Status:** $alert.status\n\n**Service:** $labels.service_name\n\n**Condition ($labels.threshold.name):**\n- **Current:** $alert.value\n- **Threshold:** $rule.threshold.op $rule.threshold.value\n\n**Description:** p90 request latency on $labels.service_name reached $alert.value, crossing the $labels.threshold.name threshold of $rule.threshold.op $rule.threshold.value. Investigate downstream dependencies and recent deploys.\n\n**Runbook:** https://signoz.io/docs/runbooks/high-latency"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -75,7 +75,3 @@ ignore = [
[tool.ruff.format]
# Defaults align with black (double quotes, 4-space indent).
[tool.ruff.lint.per-file-ignores]
"integration/src/alertmanager/*" = ["E501"]
"fixtures/notification_channel.py" = ["E501"]